init
This commit is contained in:
+82
@@ -0,0 +1,82 @@
|
||||
.woopayments-plugin-warning-modal .button {
|
||||
height: 36px;
|
||||
align-items: center;
|
||||
box-sizing: border-box;
|
||||
padding: 6px 12px;
|
||||
border-radius: 2px;
|
||||
line-height: normal;
|
||||
cursor: pointer;
|
||||
}
|
||||
.woopayments-plugin-warning-modal .button-secondary {
|
||||
background: transparent;
|
||||
}
|
||||
.woopayments-plugin-warning-modal .button.busy {
|
||||
animation: wcpay-plugin-notice-submit-button-busy 3000ms infinite linear;
|
||||
background-image: linear-gradient(
|
||||
-45deg,
|
||||
transparent 33%,
|
||||
#0000004d 33%,
|
||||
#0000004d 70%,
|
||||
transparent 70%
|
||||
);
|
||||
background-size: 100px 100%;
|
||||
}
|
||||
.woopayments-plugin-warning-modal footer .inner > *:not( :first-child ) {
|
||||
margin-left: 16px;
|
||||
}
|
||||
.woopayments-plugin-warning-modal h1 {
|
||||
font-size: 1rem;
|
||||
font-weight: 600;
|
||||
line-height: 1;
|
||||
}
|
||||
.woopayments-plugin-warning-modal header,
|
||||
.woopayments-plugin-warning-modal footer {
|
||||
background: initial;
|
||||
box-shadow: initial;
|
||||
}
|
||||
.woopayments-plugin-warning-modal header {
|
||||
margin: 0 -24px;
|
||||
padding-top: 24px;
|
||||
padding-left: 24px;
|
||||
height: 60px;
|
||||
border-bottom: 1px solid #dcdcde;
|
||||
}
|
||||
.woopayments-plugin-warning-modal footer {
|
||||
padding: 24px;
|
||||
border-top: 1px solid #dcdcde;
|
||||
}
|
||||
.woopayments-plugin-warning-modal .wc-backbone-modal-content {
|
||||
border-radius: 2px;
|
||||
padding: 0 24px 24px;
|
||||
}
|
||||
.woopayments-plugin-warning-modal .modal-close-link {
|
||||
height: 100%;
|
||||
width: 60px;
|
||||
border-left: none;
|
||||
}
|
||||
.woopayments-plugin-warning-modal .modal-close-link:hover {
|
||||
background: transparent;
|
||||
}
|
||||
.woopayments-plugin-warning-modal article {
|
||||
padding: 1.5em 0;
|
||||
}
|
||||
.woopayments-plugin-warning-modal .wc-backbone-modal-main {
|
||||
padding-bottom: 60px;
|
||||
}
|
||||
.woopayments-plugin-warning-modal .modal-close-link:hover::before {
|
||||
color: initial;
|
||||
}
|
||||
.woopayments-plugin-warning-modal ul {
|
||||
padding-left: 24px;
|
||||
list-style: disc;
|
||||
}
|
||||
.woopayments-plugin-warning-modal ul > li > ul {
|
||||
margin-top: 6px;
|
||||
list-style: circle;
|
||||
}
|
||||
|
||||
@keyframes wcpay-plugin-notice-submit-button-busy {
|
||||
0% {
|
||||
background-position: 200px 0;
|
||||
}
|
||||
}
|
||||
+93
@@ -0,0 +1,93 @@
|
||||
jQuery( function ( $ ) {
|
||||
'use strict';
|
||||
|
||||
var wc_payments_plugin = {
|
||||
init: function () {
|
||||
this.init_deactivate_wc_subscriptions_warning();
|
||||
this.init_deactivate_wcpay_warning();
|
||||
},
|
||||
|
||||
// Initialise handlers for WC Pay deactivate warning.
|
||||
init_deactivate_wcpay_warning() {
|
||||
// Intercept click on WCPay deactivate link to show modal.
|
||||
$( '#deactivate-woocommerce-payments' ).on(
|
||||
'click',
|
||||
this.display_wcpay_warning
|
||||
);
|
||||
// Resume deactivate when user confirms modal.
|
||||
$( document ).on(
|
||||
'click',
|
||||
'#wcpay-plugin-deactivate-modal-submit',
|
||||
this.redirect_deactivate_wcpay
|
||||
);
|
||||
},
|
||||
// Show a modal to warn merchant that disabling WCPay plugin may leave Stripe subscriptions active (and renewing).
|
||||
display_wcpay_warning: function ( event ) {
|
||||
event.preventDefault();
|
||||
|
||||
$( this ).WCBackboneModal( {
|
||||
template: 'wcpay-plugin-deactivate-warning',
|
||||
} );
|
||||
|
||||
return false;
|
||||
},
|
||||
// Trigger deactivate flow for WCPay.
|
||||
redirect_deactivate_wcpay: function ( event ) {
|
||||
$( '#wcpay-plugin-deactivate-modal-submit' ).addClass( 'busy' );
|
||||
|
||||
window.location = $( '#deactivate-woocommerce-payments' ).attr(
|
||||
'href'
|
||||
);
|
||||
},
|
||||
|
||||
// Initialise handlers for Woo Subscriptions deactivate warning.
|
||||
init_deactivate_wc_subscriptions_warning() {
|
||||
// Intercept click on Woo Subscriptions deactivate link to show modal.
|
||||
$( '#deactivate-' + this.get_woo_subscriptions_plugin_slug() ).on(
|
||||
'click',
|
||||
this.display_wcs_warning
|
||||
);
|
||||
// Resume deactivate when user confirms modal.
|
||||
$( document ).on(
|
||||
'click',
|
||||
'#wcpay-subscriptions-plugin-deactivation-submit',
|
||||
this.redirect_deactivate_wc_subscriptions
|
||||
);
|
||||
},
|
||||
// Show a modal to warn merchant that disabling WC Subscriptions plugin will switch to WCPay.
|
||||
display_wcs_warning: function ( event ) {
|
||||
event.preventDefault();
|
||||
|
||||
$( this ).WCBackboneModal( {
|
||||
template: 'wcpay-subscriptions-plugin-warning',
|
||||
} );
|
||||
|
||||
return false;
|
||||
},
|
||||
// Trigger deactivate flow for WC Subscriptions.
|
||||
redirect_deactivate_wc_subscriptions: function ( event ) {
|
||||
$( '#wcpay-subscriptions-plugin-deactivation-submit' ).addClass(
|
||||
'busy'
|
||||
);
|
||||
|
||||
window.location = $(
|
||||
'#deactivate-' +
|
||||
wc_payments_plugin.get_woo_subscriptions_plugin_slug()
|
||||
).attr( 'href' );
|
||||
},
|
||||
// Gets the Woo Subscriptions plugin slug. When the site is connected to WooCommerce.com, the slug is different and includes a woocommerce-com- prefix.
|
||||
get_woo_subscriptions_plugin_slug() {
|
||||
const element = document.querySelector(
|
||||
'[data-slug="woocommerce-com-woocommerce-subscriptions"]'
|
||||
);
|
||||
|
||||
if ( element ) {
|
||||
return 'woocommerce-com-woocommerce-subscriptions';
|
||||
} else {
|
||||
return 'woocommerce-subscriptions';
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
wc_payments_plugin.init();
|
||||
} );
|
||||
+475
@@ -0,0 +1,475 @@
|
||||
<?php
|
||||
/**
|
||||
* Class WC_Payments_Invoice_Service
|
||||
*
|
||||
* @package WooCommerce\Payments
|
||||
*/
|
||||
|
||||
use WCPay\Core\Server\Request\Get_Intention;
|
||||
use WCPay\Exceptions\API_Exception;
|
||||
use WCPay\Exceptions\Rest_Request_Exception;
|
||||
use WCPay\Exceptions\Order_Not_Found_Exception;
|
||||
use WCPay\Logger;
|
||||
|
||||
defined( 'ABSPATH' ) || exit;
|
||||
|
||||
/**
|
||||
* Class handling any subscription invoice functionality.
|
||||
*/
|
||||
class WC_Payments_Invoice_Service {
|
||||
|
||||
/**
|
||||
* Subscription meta key used to store subscription's last invoice ID.
|
||||
*
|
||||
* @const string
|
||||
*/
|
||||
const PENDING_INVOICE_ID_KEY = '_wcpay_pending_invoice_id';
|
||||
|
||||
/**
|
||||
* Meta key used to store invoice IDs on orders.
|
||||
*
|
||||
* @const
|
||||
*/
|
||||
const ORDER_INVOICE_ID_KEY = '_wcpay_billing_invoice_id';
|
||||
|
||||
/**
|
||||
* Client for making requests to the WooCommerce Payments API.
|
||||
*
|
||||
* @var WC_Payments_API_Client
|
||||
*/
|
||||
private $payments_api_client;
|
||||
|
||||
/**
|
||||
* Product Service
|
||||
*
|
||||
* @var WC_Payments_Product_Service
|
||||
*/
|
||||
private $product_service;
|
||||
|
||||
|
||||
/**
|
||||
* Order Service
|
||||
*
|
||||
* @var WC_Payments_Order_Service
|
||||
*/
|
||||
private $order_service;
|
||||
|
||||
/**
|
||||
* Constructor.
|
||||
*
|
||||
* @param WC_Payments_API_Client $payments_api_client WooCommerce Payments API client.
|
||||
* @param WC_Payments_Product_Service $product_service Product Service.
|
||||
* @param WC_Payments_Order_Service $order_service WC payments Order Service.
|
||||
*/
|
||||
public function __construct(
|
||||
WC_Payments_API_Client $payments_api_client,
|
||||
WC_Payments_Product_Service $product_service,
|
||||
WC_Payments_Order_Service $order_service
|
||||
) {
|
||||
$this->payments_api_client = $payments_api_client;
|
||||
$this->product_service = $product_service;
|
||||
$this->order_service = $order_service;
|
||||
|
||||
/**
|
||||
* When a store is in staging mode we don't want any order status chagnes to fire off corrisponding invoice requests to the server.
|
||||
*
|
||||
* Sending these requests from staging sites can have unintended consequences for the live store. For example, updating an unpaid
|
||||
* renewal order's status on a duplicate site, would lead to the corrisponding subscription being marked as paid in the live
|
||||
* account at Stripe.
|
||||
*/
|
||||
if ( WC_Payments_Subscriptions::is_duplicate_site() ) {
|
||||
return;
|
||||
}
|
||||
|
||||
add_action( 'woocommerce_order_payment_status_changed', [ $this, 'maybe_record_invoice_payment' ], 10, 1 );
|
||||
add_action( 'woocommerce_renewal_order_payment_complete', [ $this, 'maybe_record_invoice_payment' ], 11, 1 );
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the subscription last invoice ID from WC subscription.
|
||||
*
|
||||
* @param WC_Subscription $subscription The subscription.
|
||||
*
|
||||
* @return string Invoice ID.
|
||||
*/
|
||||
public static function get_pending_invoice_id( $subscription ): string {
|
||||
return $subscription->get_meta( self::PENDING_INVOICE_ID_KEY, true );
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the invoice ID from a WC order.
|
||||
*
|
||||
* @param WC_Order $order The order.
|
||||
* @return string Invoice ID.
|
||||
*/
|
||||
public static function get_order_invoice_id( WC_Order $order ): string {
|
||||
return $order->get_meta( self::ORDER_INVOICE_ID_KEY, true );
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the invoice ID from a WC subscription.
|
||||
*
|
||||
* @param WC_Subscription $subscription The subscription.
|
||||
*
|
||||
* @return string Invoice ID.
|
||||
*/
|
||||
public static function get_subscription_invoice_id( $subscription ) {
|
||||
return $subscription->get_meta( self::ORDER_INVOICE_ID_KEY, true );
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the WC order ID from the invoice ID.
|
||||
*
|
||||
* @param string $invoice_id The invoice ID.
|
||||
* @return int The order ID.
|
||||
*/
|
||||
public static function get_order_id_by_invoice_id( string $invoice_id ) {
|
||||
$query_args = [
|
||||
'status' => 'any',
|
||||
'type' => 'shop_order',
|
||||
'limit' => 1,
|
||||
'return' => 'ids',
|
||||
'meta_query' => [ // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_query
|
||||
[
|
||||
'key' => self::ORDER_INVOICE_ID_KEY,
|
||||
'value' => $invoice_id,
|
||||
],
|
||||
],
|
||||
];
|
||||
|
||||
// On HPOS environments we can pass meta_query directly to get_orders() as WC doesn't override it.
|
||||
if ( WC_Payments_Utils::is_hpos_tables_usage_enabled() ) {
|
||||
$order_ids = wc_get_orders( $query_args );
|
||||
} else {
|
||||
$meta_query = $query_args['meta_query'];
|
||||
unset( $query_args['meta_query'] );
|
||||
|
||||
$add_meta_query = function ( $query ) use ( $meta_query ) {
|
||||
$query['meta_query'] = $meta_query; // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_query
|
||||
return $query;
|
||||
};
|
||||
|
||||
add_filter( 'woocommerce_order_data_store_cpt_get_orders_query', $add_meta_query, 10, 2 );
|
||||
$order_ids = wc_get_orders( $query_args );
|
||||
remove_filter( 'woocommerce_order_data_store_cpt_get_orders_query', $add_meta_query, 10 );
|
||||
}
|
||||
|
||||
return (int) array_shift( $order_ids );
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets a pending invoice ID meta for a subscription.
|
||||
*
|
||||
* @param WC_Subscription $subscription The subscription to set the invoice on.
|
||||
* @param string $invoice_id The invoice ID.
|
||||
*/
|
||||
public function mark_pending_invoice_for_subscription( WC_Subscription $subscription, string $invoice_id ) {
|
||||
$this->set_pending_invoice_id( $subscription, $invoice_id );
|
||||
}
|
||||
|
||||
/**
|
||||
* Removes pending invoice id meta from subscription.
|
||||
*
|
||||
* @param WC_Subscription $subscription The Subscription.
|
||||
*/
|
||||
public function mark_pending_invoice_paid_for_subscription( WC_Subscription $subscription ) {
|
||||
$this->set_pending_invoice_id( $subscription, '' );
|
||||
}
|
||||
|
||||
/**
|
||||
* Marks a subscription invoice as paid after a parent or renewal order is completed.
|
||||
*
|
||||
* When a subscription's parent order goes from a pending payment status to a payment completed status,
|
||||
* or when a subscription with no corresponding Stripe subscription is manually renewed,
|
||||
* make sure the invoice is marked as paid (without charging the customer since it was charged on checkout).
|
||||
*
|
||||
* Note: this function has no impact on staging sites to prevent corrupting the live subscriptions on the WCPay account.
|
||||
*
|
||||
* @param int $order_id The WC order ID.
|
||||
* @throws API_Exception If the request to mark the invoice as paid fails.
|
||||
*/
|
||||
public function maybe_record_invoice_payment( int $order_id ) {
|
||||
$order = wc_get_order( $order_id );
|
||||
|
||||
if ( ! $order || self::get_order_invoice_id( $order ) ) {
|
||||
return;
|
||||
}
|
||||
|
||||
foreach ( wcs_get_subscriptions_for_order( $order, [ 'order_type' => [ 'parent', 'renewal' ] ] ) as $subscription ) {
|
||||
$invoice_id = self::get_subscription_invoice_id( $subscription );
|
||||
|
||||
if ( ! $invoice_id || ! WC_Payments_Subscription_Service::is_wcpay_subscription( $subscription ) ) {
|
||||
continue;
|
||||
}
|
||||
|
||||
try {
|
||||
// Set the invoice status to paid but don't charge the customer by using paid_out_of_band parameter.
|
||||
$this->payments_api_client->charge_invoice( $invoice_id, [ 'paid_out_of_band' => 'true' ] );
|
||||
} catch ( API_Exception $e ) {
|
||||
// If the invoice was already paid, silently handle that error. Throw all other exceptions.
|
||||
if ( WP_Http::BAD_REQUEST === $e->get_http_code() && false !== stripos( $e->getMessage(), 'invoice is already paid' ) ) {
|
||||
Logger::info( sprintf( 'Invoice for subscription #%s has already been paid.', $subscription->get_id() ) );
|
||||
} else {
|
||||
throw $e;
|
||||
}
|
||||
}
|
||||
|
||||
if ( $subscription->is_manual() ) {
|
||||
$subscription->set_requires_manual_renewal( false );
|
||||
$subscription->set_payment_method( WC_Payment_Gateway_WCPay::GATEWAY_ID );
|
||||
|
||||
// Copy the payment token used to pay for the order to the subscription.
|
||||
WC_Payments::get_gateway()->update_failing_payment_method( $subscription, $order );
|
||||
$subscription->save();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Validates a WCPay invoice.
|
||||
*
|
||||
* @param array $wcpay_item_data The WCPay invoice items.
|
||||
* @param array $wcpay_discount_data The WCPay invoice discounts.
|
||||
* @param WC_Subscription $subscription The WC Subscription object.
|
||||
*
|
||||
* @throws API_Exception If updating the WCPay subscription or items fails.
|
||||
* @throws Rest_Request_Exception If WCPay invoice items do not match WC subscription items.
|
||||
*/
|
||||
public function validate_invoice( array $wcpay_item_data, array $wcpay_discount_data, WC_Subscription $subscription ) {
|
||||
$item_data = $this->get_repair_data_for_wcpay_items( $wcpay_item_data, $subscription );
|
||||
|
||||
if ( ! empty( $item_data ) ) {
|
||||
foreach ( $item_data as $id => $data ) {
|
||||
$this->payments_api_client->update_subscription_item( $id, $data );
|
||||
}
|
||||
}
|
||||
|
||||
$discount_data = $this->get_repair_data_for_wcpay_discounts( $wcpay_discount_data, $subscription );
|
||||
|
||||
if ( isset( $discount_data ) ) {
|
||||
$response = $this->payments_api_client->update_subscription(
|
||||
WC_Payments_Subscription_Service::get_wcpay_subscription_id( $subscription ),
|
||||
[ 'discounts' => $discount_data ]
|
||||
);
|
||||
|
||||
WC_Payments_Subscription_Service::set_wcpay_discount_ids( $subscription, $response['discounts'] );
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets the subscription's last invoice ID meta for WC subscription.
|
||||
*
|
||||
* @param WC_Order $order The order.
|
||||
* @param string $invoice_id The invoice ID.
|
||||
*/
|
||||
public function set_order_invoice_id( WC_Order $order, string $invoice_id ) {
|
||||
$order->update_meta_data( self::ORDER_INVOICE_ID_KEY, $invoice_id );
|
||||
$order->save();
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets the subscription's last invoice ID meta for WC subscription.
|
||||
*
|
||||
* @param WC_Subscription $subscription The subscription.
|
||||
* @param string $parent_invoice_id The parent order invoice ID.
|
||||
*/
|
||||
public function set_subscription_invoice_id( WC_Subscription $subscription, string $parent_invoice_id ) {
|
||||
$subscription->update_meta_data( self::ORDER_INVOICE_ID_KEY, $parent_invoice_id );
|
||||
$subscription->save();
|
||||
}
|
||||
|
||||
/**
|
||||
* Retrieves the intent object and adds its data to the order.
|
||||
*
|
||||
* @param WC_Order $order The order to update.
|
||||
* @param string $intent_id The intent ID.
|
||||
*
|
||||
* @throws Order_Not_Found_Exception
|
||||
*/
|
||||
public function get_and_attach_intent_info_to_order( $order, $intent_id ) {
|
||||
try {
|
||||
$request = Get_Intention::create( $intent_id );
|
||||
$request->set_hook_args( $order );
|
||||
$intent_object = $request->send();
|
||||
|
||||
} catch ( API_Exception $e ) {
|
||||
$order->add_order_note( __( 'The payment info couldn\'t be added to the order.', 'woocommerce-payments' ) );
|
||||
return;
|
||||
}
|
||||
|
||||
$charge = $intent_object->get_charge();
|
||||
|
||||
$this->order_service->attach_intent_info_to_order( $order, $intent_object );
|
||||
}
|
||||
|
||||
/**
|
||||
* Sends a request to server to record the store's context for an invoice payment.
|
||||
*
|
||||
* @param string $invoice_id The subscription invoice ID.
|
||||
*
|
||||
* @return array
|
||||
* @throws API_Exception
|
||||
*/
|
||||
public function record_subscription_payment_context( string $invoice_id ) {
|
||||
return $this->payments_api_client->update_invoice(
|
||||
$invoice_id,
|
||||
[
|
||||
'subscription_context' => class_exists( 'WC_Subscriptions' ) && WC_Payments_Features::is_stripe_billing_enabled() ? 'stripe_billing' : 'legacy_wcpay_subscription',
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Sends a request to server to update transaction details.
|
||||
*
|
||||
* @param array $invoice Invoice details.
|
||||
* @param WC_Order $order Order details.
|
||||
*
|
||||
* @return void
|
||||
* @throws API_Exception
|
||||
*/
|
||||
public function update_transaction_details( array $invoice, WC_Order $order ) {
|
||||
if ( ! isset( $invoice['charge'] ) ) {
|
||||
return;
|
||||
}
|
||||
|
||||
$charge = $this->payments_api_client->get_charge( $invoice['charge'] );
|
||||
if ( ! isset( $charge['balance_transaction'] ) || ! isset( $charge['balance_transaction']['id'] ) ) {
|
||||
return;
|
||||
}
|
||||
|
||||
$this->payments_api_client->update_transaction(
|
||||
$charge['balance_transaction']['id'],
|
||||
[
|
||||
'customer_first_name' => $order->get_billing_first_name(),
|
||||
'customer_last_name' => $order->get_billing_last_name(),
|
||||
'customer_email' => $order->get_billing_email(),
|
||||
'customer_country' => $order->get_billing_country(),
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Update a charge with the order id from invoice.
|
||||
*
|
||||
* @param array $invoice Invoice details.
|
||||
* @param int $order_id Order ID.
|
||||
*
|
||||
* @return void
|
||||
* @throws API_Exception
|
||||
*/
|
||||
public function update_charge_details( array $invoice, int $order_id ) {
|
||||
if ( ! isset( $invoice['charge'] ) ) {
|
||||
return;
|
||||
}
|
||||
$this->payments_api_client->update_charge(
|
||||
$invoice['charge'],
|
||||
[
|
||||
'metadata' => [ 'order_id' => $order_id ],
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets the subscription last invoice ID meta for WC subscription.
|
||||
*
|
||||
* @param WC_Subscription $subscription The subscription.
|
||||
* @param string $invoice_id The invoice ID.
|
||||
*/
|
||||
private function set_pending_invoice_id( $subscription, string $invoice_id ) {
|
||||
$subscription->update_meta_data( self::PENDING_INVOICE_ID_KEY, $invoice_id );
|
||||
$subscription->save();
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets repair data for WCPay invoice items.
|
||||
*
|
||||
* @param array $wcpay_item_data The WCPay invoice items.
|
||||
* @param WC_Subscription $subscription The WC Subscription object.
|
||||
*
|
||||
* @return array Repair data.
|
||||
*
|
||||
* @throws Rest_Request_Exception WCPay invoice items do not match WC subscription items.
|
||||
*/
|
||||
private function get_repair_data_for_wcpay_items( array $wcpay_item_data, WC_Subscription $subscription ): array {
|
||||
$repair_data = [];
|
||||
$wcpay_items = [];
|
||||
$subscription_items = $subscription->get_items( [ 'line_item', 'fee', 'shipping', 'tax' ] );
|
||||
|
||||
foreach ( $wcpay_item_data as $item ) {
|
||||
$wcpay_subscription_item_id = $item['subscription_item'];
|
||||
|
||||
$wcpay_items[ $wcpay_subscription_item_id ] = [
|
||||
'unit_amount' => $item['price']['unit_amount_decimal'],
|
||||
'billing_period' => $item['price']['recurring']['interval'],
|
||||
'billing_interval' => $item['price']['recurring']['interval_count'],
|
||||
'currency' => $item['price']['currency'],
|
||||
'quantity' => $item['quantity'],
|
||||
];
|
||||
}
|
||||
|
||||
// Generate any repair data necessary to update the WCPay Subscription so it matches the WC subscription.
|
||||
foreach ( WC_Payments_Subscriptions::get_subscription_service()->get_recurring_item_data_for_subscription( $subscription ) as $recurring_item_data ) {
|
||||
$item_id = $recurring_item_data['metadata']['wc_item_id'];
|
||||
$item = $subscription_items[ $item_id ];
|
||||
$wcpay_item_id = WC_Payments_Subscription_Service::get_wcpay_subscription_item_id( $item );
|
||||
|
||||
if ( ! isset( $wcpay_items[ $wcpay_item_id ] ) ) {
|
||||
$message = __( 'The WCPay invoice items do not match WC subscription items.', 'woocommerce-payments' );
|
||||
Logger::error( $message );
|
||||
throw new Rest_Request_Exception( $message );
|
||||
}
|
||||
|
||||
// Check the quantity matches between WC and WCPay.
|
||||
if ( $item->is_type( 'line_item' ) && $wcpay_items[ $wcpay_item_id ]['quantity'] !== $recurring_item_data['quantity'] ) {
|
||||
$repair_data[ $wcpay_item_id ]['quantity'] = $recurring_item_data['quantity'];
|
||||
}
|
||||
|
||||
// Confirm the line item amount matches between WC and WCPay.
|
||||
$unit_amounts_match = (string) $wcpay_items[ $wcpay_item_id ]['unit_amount'] === (string) $recurring_item_data['price_data']['unit_amount_decimal'];
|
||||
|
||||
if ( ! $unit_amounts_match ) {
|
||||
$price_data = $recurring_item_data['price_data'];
|
||||
|
||||
// We need to maintain the WCPay subscription's billing terms and currency. ie We cannot update the recurring period, interval or currency mid term.
|
||||
$price_data['currency'] = $wcpay_items[ $wcpay_item_id ]['currency'];
|
||||
$price_data['recurring'] = [
|
||||
'interval' => $wcpay_items[ $wcpay_item_id ]['billing_period'],
|
||||
'interval_count' => $wcpay_items[ $wcpay_item_id ]['billing_interval'],
|
||||
];
|
||||
|
||||
$repair_data[ $wcpay_item_id ]['price_data'] = $price_data;
|
||||
}
|
||||
}
|
||||
|
||||
return $repair_data;
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets repair data for WCPay invoice discounts.
|
||||
*
|
||||
* @param array $wcpay_discount_data The WCPay discounts.
|
||||
* @param WC_Subscription $subscription The WC Subscription object.
|
||||
*
|
||||
* @return mixed
|
||||
*/
|
||||
private function get_repair_data_for_wcpay_discounts( array $wcpay_discount_data, WC_Subscription $subscription ) {
|
||||
$repair_data = null;
|
||||
$subscription_discount_ids = WC_Payments_Subscription_Service::get_wcpay_discount_ids( $subscription );
|
||||
|
||||
if ( ! empty( $subscription_discount_ids ) || ! empty( $wcpay_discount_data ) ) {
|
||||
if ( count( $subscription_discount_ids ) !== count( $wcpay_discount_data ) ) {
|
||||
$repair_data = WC_Payments_Subscription_Service::get_discount_item_data_for_subscription( $subscription );
|
||||
} else {
|
||||
foreach ( $subscription_discount_ids as $discount_id ) {
|
||||
if ( ! in_array( $discount_id, $wcpay_discount_data, true ) ) {
|
||||
$repair_data = WC_Payments_Subscription_Service::get_discount_item_data_for_subscription( $subscription );
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return $repair_data;
|
||||
}
|
||||
}
|
||||
+797
@@ -0,0 +1,797 @@
|
||||
<?php
|
||||
/**
|
||||
* Class WC_Payments_Product_Service
|
||||
*
|
||||
* @package WooCommerce\Payments
|
||||
*/
|
||||
|
||||
use WCPay\Exceptions\API_Exception;
|
||||
use WCPay\Logger;
|
||||
|
||||
defined( 'ABSPATH' ) || exit;
|
||||
|
||||
/**
|
||||
* Class handling any subscription product functionality
|
||||
*/
|
||||
class WC_Payments_Product_Service {
|
||||
|
||||
use WC_Payments_Subscriptions_Utilities;
|
||||
|
||||
/**
|
||||
* The product meta key used to store the product data we last sent to WC Pay as a hash. Used to compare current WC product data with WC Pay data.
|
||||
*
|
||||
* @const string
|
||||
*/
|
||||
const PRODUCT_HASH_KEY = '_wcpay_product_hash';
|
||||
|
||||
/**
|
||||
* The live product meta key used to store the product's ID in WC Pay.
|
||||
*
|
||||
* @const string
|
||||
*/
|
||||
const LIVE_PRODUCT_ID_KEY = '_wcpay_product_id_live';
|
||||
|
||||
/**
|
||||
* The testmode product meta key used to store the product's ID in WC Pay.
|
||||
*
|
||||
* @const string
|
||||
*/
|
||||
const TEST_PRODUCT_ID_KEY = '_wcpay_product_id_test';
|
||||
|
||||
/**
|
||||
* The product price meta key used to store the price data we last sent to WC Pay as a hash. Used to compare current WC product price data with WC Pay data.
|
||||
*
|
||||
* @const string
|
||||
*/
|
||||
const PRICE_HASH_KEY = '_wcpay_product_price_hash';
|
||||
|
||||
/**
|
||||
* The product meta key used to store the live product's WC Pay Price object ID.
|
||||
*
|
||||
* @const string
|
||||
*/
|
||||
const LIVE_PRICE_ID_KEY = '_wcpay_product_price_id_live';
|
||||
|
||||
/**
|
||||
* The product meta key used to store the testmode product's WC Pay Price object ID.
|
||||
*
|
||||
* @const string
|
||||
*/
|
||||
const TEST_PRICE_ID_KEY = '_wcpay_product_price_id_test';
|
||||
|
||||
/**
|
||||
* Client for making requests to the WooCommerce Payments API
|
||||
*
|
||||
* @var WC_Payments_API_Client
|
||||
*/
|
||||
private $payments_api_client;
|
||||
|
||||
/**
|
||||
* The list of products we need to update at the end of each request.
|
||||
*
|
||||
* @var array
|
||||
*/
|
||||
private $products_to_update = [];
|
||||
|
||||
/**
|
||||
* Constructor.
|
||||
*
|
||||
* @param WC_Payments_API_Client $payments_api_client Payments API client.
|
||||
*/
|
||||
public function __construct( WC_Payments_API_Client $payments_api_client ) {
|
||||
$this->payments_api_client = $payments_api_client;
|
||||
|
||||
/**
|
||||
* When a store is in staging mode, we don't want any product handling to be sent to the server.
|
||||
*
|
||||
* Sending these requests from staging sites can have unintended consequences for the live store. For example,
|
||||
* deleting a subscription product on a staging site would delete the product record at Stripe and that product
|
||||
* would be in use for the live site.
|
||||
*/
|
||||
if ( WC_Payments_Subscriptions::is_duplicate_site() ) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Only create, update and restore/unarchive WCPay Subscription products when Stripe Billing is active.
|
||||
if ( WC_Payments_Features::should_use_stripe_billing() ) {
|
||||
add_action( 'shutdown', [ $this, 'create_or_update_products' ] );
|
||||
add_action( 'untrashed_post', [ $this, 'maybe_unarchive_product' ] );
|
||||
|
||||
$this->add_product_update_listeners();
|
||||
}
|
||||
|
||||
add_action( 'wp_trash_post', [ $this, 'maybe_archive_product' ] );
|
||||
add_filter( 'woocommerce_duplicate_product_exclude_meta', [ $this, 'exclude_meta_wcpay_product' ] );
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the WC Pay product hash associated with a WC product.
|
||||
*
|
||||
* @param WC_Product $product The product to get the hash for.
|
||||
* @return string The product's hash or an empty string.
|
||||
*/
|
||||
public static function get_wcpay_product_hash( WC_Product $product ): string {
|
||||
return $product->get_meta( self::PRODUCT_HASH_KEY, true );
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the WC Pay product ID associated with a WC product.
|
||||
*
|
||||
* @param WC_Product $product The product to get the WC Pay ID for.
|
||||
* @param bool|null $test_mode Is WC Pay in test/dev mode.
|
||||
*
|
||||
* @return string The WC Pay product ID or an empty string.
|
||||
*/
|
||||
public function get_wcpay_product_id( WC_Product $product, $test_mode = null ): string {
|
||||
// If the subscription product doesn't have a WC Pay product ID, create one.
|
||||
if ( ! self::has_wcpay_product_id( $product, $test_mode ) ) {
|
||||
$is_current_environment = null === $test_mode || WC_Payments::mode()->is_test() === $test_mode;
|
||||
|
||||
// Only create a new wcpay product if we're trying to fetch a wcpay product ID in the current environment.
|
||||
if ( $is_current_environment ) {
|
||||
WC_Payments_Subscriptions::get_product_service()->create_product( $product );
|
||||
}
|
||||
}
|
||||
|
||||
return $product->get_meta( self::get_wcpay_product_id_option( $test_mode ), true );
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the WC Pay product ID associated with a WC product.
|
||||
*
|
||||
* @param string $type The item type to create a product for.
|
||||
* @return string The item's WCPay product id.
|
||||
*/
|
||||
public function get_wcpay_product_id_for_item( string $type ): string {
|
||||
$sanitized_type = self::sanitize_option_key( $type );
|
||||
$option_key_name = self::get_wcpay_product_id_option() . '_' . $sanitized_type;
|
||||
if ( ! get_option( $option_key_name ) ) {
|
||||
$this->create_product_for_item_type( $sanitized_type );
|
||||
}
|
||||
return get_option( $option_key_name );
|
||||
}
|
||||
|
||||
/**
|
||||
* Sanitize option key string to replace space with underscore, and remove special characters.
|
||||
*
|
||||
* @param string $type Non sanitized input.
|
||||
* @return string Sanitized output.
|
||||
*/
|
||||
public static function sanitize_option_key( string $type ) {
|
||||
return sanitize_key( str_replace( ' ', '_', trim( $type ) ) );
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if the WC product has a WC Pay product ID.
|
||||
*
|
||||
* @param WC_Product $product The product to get the WC Pay ID for.
|
||||
* @param bool|null $test_mode Is WC Pay in test/dev mode.
|
||||
*
|
||||
* @return bool The WC Pay product ID or an empty string.
|
||||
*/
|
||||
public static function has_wcpay_product_id( WC_Product $product, $test_mode = null ): bool {
|
||||
return (bool) $product->get_meta( self::get_wcpay_product_id_option( $test_mode ) );
|
||||
}
|
||||
|
||||
/**
|
||||
* Prevents duplicate WC Pay product IDs and hashes when duplicating a subscription product.
|
||||
*
|
||||
* @param array $meta_keys The keys to exclude from the duplicate.
|
||||
* @return array Keys to exclude.
|
||||
*/
|
||||
public static function exclude_meta_wcpay_product( $meta_keys ) {
|
||||
return array_merge(
|
||||
$meta_keys,
|
||||
[
|
||||
self::PRODUCT_HASH_KEY,
|
||||
self::LIVE_PRODUCT_ID_KEY,
|
||||
self::TEST_PRODUCT_ID_KEY,
|
||||
self::PRICE_HASH_KEY,
|
||||
self::LIVE_PRICE_ID_KEY,
|
||||
self::TEST_PRICE_ID_KEY,
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Schedules a subscription product to be created or updated in WC Pay on shutdown.
|
||||
*
|
||||
* @since 3.2.0
|
||||
*
|
||||
* @param int $product_id The ID of the product to handle.
|
||||
*/
|
||||
public function maybe_schedule_product_create_or_update( int $product_id ) {
|
||||
// Skip products which have already been scheduled or aren't subscriptions.
|
||||
$product = wc_get_product( $product_id );
|
||||
if ( ! $product || isset( $this->products_to_update[ $product_id ] ) || ! WC_Subscriptions_Product::is_subscription( $product ) ) {
|
||||
return;
|
||||
}
|
||||
|
||||
foreach ( $this->get_products_to_update( $product ) as $product_to_update ) {
|
||||
// Skip products already scheduled.
|
||||
if ( isset( $this->products_to_update[ $product_to_update->get_id() ] ) ) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Skip product variations that don't have a price set.
|
||||
if ( $product_to_update->is_type( 'subscription_variation' ) && '' === $product_to_update->get_price() ) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if ( ! self::has_wcpay_product_id( $product_to_update ) || $this->product_needs_update( $product_to_update ) ) {
|
||||
$this->products_to_update[ $product_to_update->get_id() ] = $product_to_update->get_id();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates and updates all products which have been scheduled for an update.
|
||||
*
|
||||
* Hooked onto shutdown so all products which have been changed in the current request can be updated once.
|
||||
*
|
||||
* @since 3.2.0
|
||||
*/
|
||||
public function create_or_update_products() {
|
||||
foreach ( $this->products_to_update as $product_id ) {
|
||||
$product = wc_get_product( $product_id );
|
||||
|
||||
if ( ! $product ) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$this->update_products( $product );
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a product in WC Pay.
|
||||
*
|
||||
* @param WC_Product $product The product to create.
|
||||
*/
|
||||
public function create_product( WC_Product $product ) {
|
||||
try {
|
||||
$product_data = $this->get_product_data( $product );
|
||||
|
||||
// Validate that we have enough data to create the product.
|
||||
$this->validate_product_data( $product_data );
|
||||
|
||||
$wcpay_product = $this->payments_api_client->create_product( $product_data );
|
||||
|
||||
$this->remove_product_update_listeners();
|
||||
$this->set_wcpay_product_hash( $product, $this->get_product_hash( $product ) );
|
||||
$this->set_wcpay_product_id( $product, $wcpay_product['wcpay_product_id'] );
|
||||
$this->add_product_update_listeners();
|
||||
} catch ( \Exception $e ) {
|
||||
Logger::log( sprintf( 'There was a problem creating the product #%s in WC Pay: %s', $product->get_id(), $e->getMessage() ) );
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a generic item product in WC Pay.
|
||||
*
|
||||
* @param string $type The item type to create a product for.
|
||||
*/
|
||||
public function create_product_for_item_type( string $type ) {
|
||||
try {
|
||||
$wcpay_product = $this->payments_api_client->create_product(
|
||||
[
|
||||
'description' => 'N/A',
|
||||
'name' => ucfirst( $type ),
|
||||
]
|
||||
);
|
||||
|
||||
update_option( self::get_wcpay_product_id_option() . '_' . $type, $wcpay_product['wcpay_product_id'] );
|
||||
} catch ( API_Exception $e ) {
|
||||
Logger::log( 'There was a problem creating the product on WCPay Server: ' . $e->getMessage() );
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Updates related products in WC Pay when a WC Product is updated.
|
||||
*
|
||||
* @param WC_Product $product The product to update.
|
||||
*/
|
||||
public function update_products( WC_Product $product ) {
|
||||
if ( ! WC_Subscriptions_Product::is_subscription( $product ) ) {
|
||||
return;
|
||||
}
|
||||
|
||||
$wcpay_product_ids = $this->get_all_wcpay_product_ids( $product );
|
||||
$test_mode = WC_Payments::mode()->is_test();
|
||||
|
||||
// If the current environment doesn't have a product ID, make sure we create one.
|
||||
if ( ! isset( $wcpay_product_ids[ $test_mode ? 'test' : 'live' ] ) ) {
|
||||
$this->create_product( $product );
|
||||
}
|
||||
|
||||
// Return when there's no products to update.
|
||||
if ( empty( $wcpay_product_ids ) ) {
|
||||
return;
|
||||
}
|
||||
|
||||
if ( ! $this->product_needs_update( $product ) ) {
|
||||
return;
|
||||
}
|
||||
|
||||
$data = $this->get_product_data( $product );
|
||||
|
||||
$this->remove_product_update_listeners();
|
||||
|
||||
try {
|
||||
// Validate that we have enough data to create the product.
|
||||
$this->validate_product_data( $data );
|
||||
|
||||
// Update all versions of WCPay Products that need updating.
|
||||
foreach ( $wcpay_product_ids as $environment => $wcpay_product_id ) {
|
||||
$data['test_mode'] = 'live' !== $environment;
|
||||
$this->payments_api_client->update_product( $wcpay_product_id, $data );
|
||||
}
|
||||
|
||||
$this->set_wcpay_product_hash( $product, $this->get_product_hash( $product ) );
|
||||
} catch ( \Exception $e ) {
|
||||
Logger::log( sprintf( 'There was a problem updating the product #%s in WC Pay: %s', $product->get_id(), $e->getMessage() ) );
|
||||
}
|
||||
|
||||
$this->add_product_update_listeners();
|
||||
}
|
||||
|
||||
/**
|
||||
* Archives a subscription product in WC Pay.
|
||||
*
|
||||
* @since 3.2.0
|
||||
*
|
||||
* @param int $post_id The ID of the post to handle. Only subscription product IDs will be archived in WC Pay.
|
||||
*/
|
||||
public function maybe_archive_product( int $post_id ) {
|
||||
$product = wc_get_product( $post_id );
|
||||
|
||||
if ( $product && WC_Subscriptions_Product::is_subscription( $product ) ) {
|
||||
foreach ( $this->get_products_to_update( $product ) as $product ) {
|
||||
$this->archive_product( $product );
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Unarchives a subscription product in WC Pay.
|
||||
*
|
||||
* @since 3.2.0
|
||||
*
|
||||
* @param int $post_id The ID of the post to handle. Only Subscription product post IDs will be unarchived in WC Pay.
|
||||
*/
|
||||
public function maybe_unarchive_product( int $post_id ) {
|
||||
$product = wc_get_product( $post_id );
|
||||
|
||||
if ( $product && WC_Subscriptions_Product::is_subscription( $product ) ) {
|
||||
foreach ( $this->get_products_to_update( $product ) as $product ) {
|
||||
$this->unarchive_product( $product );
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Archives all related WCPay products (live and test) when a product is trashed/deleted in WC.
|
||||
*
|
||||
* @param WC_Product $product The product to archive.
|
||||
*/
|
||||
public function archive_product( WC_Product $product ) {
|
||||
$wcpay_product_ids = $this->get_all_wcpay_product_ids( $product );
|
||||
|
||||
if ( empty( $wcpay_product_ids ) ) {
|
||||
return;
|
||||
}
|
||||
|
||||
foreach ( $wcpay_product_ids as $environment => $wcpay_product_id ) {
|
||||
try {
|
||||
$this->delete_all_wcpay_price_ids( $product );
|
||||
|
||||
$this->payments_api_client->update_product(
|
||||
$wcpay_product_id,
|
||||
[
|
||||
'active' => 'false',
|
||||
'test_mode' => 'live' !== $environment,
|
||||
]
|
||||
);
|
||||
} catch ( API_Exception $e ) {
|
||||
Logger::log( 'There was a problem archiving the ' . $environment . ' product in WC Pay: ' . $e->getMessage() );
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Unarchives all related WCPay products (live and test) when a product in WC is untrashed.
|
||||
*
|
||||
* @param WC_Product $product The product unarchive.
|
||||
*/
|
||||
public function unarchive_product( WC_Product $product ) {
|
||||
$wcpay_product_ids = $this->get_all_wcpay_product_ids( $product );
|
||||
|
||||
if ( empty( $wcpay_product_ids ) ) {
|
||||
return;
|
||||
}
|
||||
|
||||
foreach ( $wcpay_product_ids as $environment => $wcpay_product_id ) {
|
||||
try {
|
||||
$this->payments_api_client->update_product(
|
||||
$wcpay_product_id,
|
||||
[
|
||||
'active' => 'true',
|
||||
'test_mode' => 'live' !== $environment,
|
||||
]
|
||||
);
|
||||
} catch ( API_Exception $e ) {
|
||||
Logger::log( 'There was a problem unarchiving the ' . $environment . 'product in WC Pay: ' . $e->getMessage() );
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Archives a WC Pay price object.
|
||||
*
|
||||
* @param string $wcpay_price_id The price object's ID to archive.
|
||||
* @param bool|null $test_mode Is WC Pay in test/dev mode.
|
||||
*/
|
||||
public function archive_price( string $wcpay_price_id, $test_mode = null ) {
|
||||
$data = [ 'active' => 'false' ];
|
||||
|
||||
if ( null !== $test_mode ) {
|
||||
$data['test_mode'] = $test_mode;
|
||||
}
|
||||
|
||||
$this->payments_api_client->update_price( $wcpay_price_id, $data );
|
||||
}
|
||||
|
||||
/**
|
||||
* Prevents the subscription interval to be greater than 1 for yearly subscriptions.
|
||||
*
|
||||
* @param int $product_id ID of the product that's being saved.
|
||||
*/
|
||||
public function limit_subscription_product_intervals( $product_id ) {
|
||||
if ( $this->is_subscriptions_plugin_active() ) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Skip products that aren't subscriptions.
|
||||
$product = wc_get_product( $product_id );
|
||||
|
||||
if (
|
||||
! $product ||
|
||||
! WC_Subscriptions_Product::is_subscription( $product ) ||
|
||||
empty( $_POST['_wcsnonce'] ) ||
|
||||
! wp_verify_nonce( sanitize_key( $_POST['_wcsnonce'] ), 'wcs_subscription_meta' )
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
// If we don't have both the period and the interval, there's nothing to do here.
|
||||
if ( empty( $_REQUEST['_subscription_period'] ) || empty( $_REQUEST['_subscription_period_interval'] ) ) {
|
||||
return;
|
||||
}
|
||||
|
||||
$period = sanitize_text_field( wp_unslash( $_REQUEST['_subscription_period'] ) );
|
||||
$interval = absint( wp_unslash( $_REQUEST['_subscription_period_interval'] ) );
|
||||
|
||||
// Prevent WC Subs Core from saving the interval when it's invalid.
|
||||
if ( ! $this->is_valid_billing_cycle( $period, $interval ) ) {
|
||||
$new_interval = $this->get_period_interval_limit( $period );
|
||||
$_REQUEST['_subscription_period_interval'] = (string) $new_interval;
|
||||
|
||||
/* translators: %1$s Opening strong tag, %2$s Closing strong tag, %3$s The subscription renewal interval (every x time) */
|
||||
wcs_add_admin_notice( sprintf( __( '%1$sThere was an issue saving your product!%2$s A subscription product\'s billing period cannot be longer than one year. We have updated this product to renew every %3$s.', 'woocommerce-payments' ), '<strong>', '</strong>', wcs_get_subscription_period_strings( $new_interval, $period ) ), 'error' );
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Prevents the subscription interval to be greater than 1 for yearly subscription variations.
|
||||
*
|
||||
* @param int $product_id Post ID of the variation.
|
||||
* @param int $index Variation index in the incoming array.
|
||||
*/
|
||||
public function limit_subscription_variation_intervals( $product_id, $index ) {
|
||||
if ( $this->is_subscriptions_plugin_active() ) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Skip products that aren't subscriptions.
|
||||
$product = wc_get_product( $product_id );
|
||||
$admin_notice_sent = false;
|
||||
|
||||
if (
|
||||
! $product ||
|
||||
! WC_Subscriptions_Product::is_subscription( $product ) ||
|
||||
empty( $_POST['_wcsnonce_save_variations'] ) ||
|
||||
! wp_verify_nonce( sanitize_key( $_POST['_wcsnonce_save_variations'] ), 'wcs_subscription_variations' )
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
// If we don't have both the period and the interval, there's nothing to do here.
|
||||
if ( empty( $_POST['variable_subscription_period'][ $index ] ) || empty( $_POST['variable_subscription_period_interval'][ $index ] ) ) {
|
||||
return;
|
||||
}
|
||||
|
||||
$period = sanitize_text_field( wp_unslash( $_POST['variable_subscription_period'][ $index ] ) );
|
||||
$interval = absint( wp_unslash( $_POST['variable_subscription_period_interval'][ $index ] ) );
|
||||
|
||||
// Prevent WC Subs Core from saving the interval when it's invalid.
|
||||
if ( ! $this->is_valid_billing_cycle( $period, $interval ) ) {
|
||||
$new_interval = $this->get_period_interval_limit( $period );
|
||||
$_POST['variable_subscription_period_interval'][ $index ] = (string) $new_interval;
|
||||
|
||||
if ( false === $admin_notice_sent ) {
|
||||
$admin_notice_sent = true;
|
||||
|
||||
/* translators: %1$s Opening strong tag, %2$s Closing strong tag */
|
||||
wcs_add_admin_notice( sprintf( __( '%1$sThere was an issue saving your variations!%2$s A subscription product\'s billing period cannot be longer than one year. We have updated one or more of this product\'s variations to renew every %3$s.', 'woocommerce-payments' ), '<strong>', '</strong>', wcs_get_subscription_period_strings( $new_interval, $period ) ), 'error' );
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Attaches the callbacks used to update product changes in WC Pay.
|
||||
*/
|
||||
private function add_product_update_listeners() {
|
||||
// This needs to run before WC_Subscriptions_Admin::save_subscription_meta(), which has a priority of 11.
|
||||
add_action( 'save_post', [ $this, 'limit_subscription_product_intervals' ], 10 );
|
||||
// This needs to run before WC_Subscriptions_Admin::save_product_variation(), which has a priority of 20.
|
||||
add_action( 'woocommerce_save_product_variation', [ $this, 'limit_subscription_variation_intervals' ], 19, 2 );
|
||||
|
||||
add_action( 'save_post_product', [ $this, 'maybe_schedule_product_create_or_update' ], 12 );
|
||||
add_action( 'woocommerce_save_product_variation', [ $this, 'maybe_schedule_product_create_or_update' ], 30 );
|
||||
}
|
||||
|
||||
/**
|
||||
* Removes the callbacks used to update product changes in WC Pay.
|
||||
*/
|
||||
private function remove_product_update_listeners() {
|
||||
remove_action( 'save_post', [ $this, 'limit_subscription_product_intervals' ], 10 );
|
||||
remove_action( 'woocommerce_save_product_variation', [ $this, 'limit_subscription_variation_intervals' ], 19 );
|
||||
|
||||
remove_action( 'save_post_product', [ $this, 'maybe_schedule_product_create_or_update' ], 12 );
|
||||
remove_action( 'woocommerce_save_product_variation', [ $this, 'maybe_schedule_product_create_or_update' ], 30 );
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets product data relevant to WC Pay from a WC product.
|
||||
*
|
||||
* @param WC_Product $product The product to get data from.
|
||||
* @return array
|
||||
*/
|
||||
private function get_product_data( WC_Product $product ): array {
|
||||
return [
|
||||
'description' => $product->get_description() ? $product->get_description() : 'N/A',
|
||||
'name' => $product->get_name(),
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the products to update from a given product.
|
||||
*
|
||||
* If applicable, returns the product's variations otherwise returns the product by itself.
|
||||
*
|
||||
* @param WC_Product|WC_Product_Variable $product The product.
|
||||
*
|
||||
* @return array The products to update.
|
||||
*/
|
||||
private function get_products_to_update( WC_Product $product ): array {
|
||||
return $product->is_type( 'variable-subscription' ) ? $product->get_available_variations( 'object' ) : [ $product ];
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets a hash of the product's name and description.
|
||||
* Used to compare WC changes with WC Pay data.
|
||||
*
|
||||
* @param WC_Product $product The product to generate the hash for.
|
||||
* @return string The product's hash.
|
||||
*/
|
||||
private function get_product_hash( WC_Product $product ): string {
|
||||
return md5( implode( $this->get_product_data( $product ) ) );
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if a product needs to be updated in WC Pay.
|
||||
*
|
||||
* @param WC_Product $product The product to check updates for.
|
||||
*
|
||||
* @return bool Whether the product needs to be update in WC Pay.
|
||||
*/
|
||||
private function product_needs_update( WC_Product $product ): bool {
|
||||
return $this->get_product_hash( $product ) !== static::get_wcpay_product_hash( $product );
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets a WC Pay product hash on a WC product.
|
||||
*
|
||||
* @param WC_Product $product The product to set the WC Pay product hash for.
|
||||
* @param string $value The WC Pay product hash.
|
||||
*/
|
||||
private function set_wcpay_product_hash( WC_Product $product, string $value ) {
|
||||
$product->update_meta_data( self::PRODUCT_HASH_KEY, $value );
|
||||
$product->save();
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets a WC Pay product ID on a WC product.
|
||||
*
|
||||
* @param WC_Product $product The product to set the WC Pay ID for.
|
||||
* @param string $value The WC Pay product ID.
|
||||
*/
|
||||
private function set_wcpay_product_id( WC_Product $product, string $value ) {
|
||||
$product->update_meta_data( self::get_wcpay_product_id_option(), $value );
|
||||
$product->save();
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the name of the product id option meta, taking test mode into account.
|
||||
*
|
||||
* @param bool|null $test_mode Is WC Pay in test/dev mode.
|
||||
*
|
||||
* @return string The WCPay product ID meta key/option name.
|
||||
*/
|
||||
public static function get_wcpay_product_id_option( $test_mode = null ): string {
|
||||
$test_mode = null === $test_mode ? WC_Payments::mode()->is_test() : $test_mode;
|
||||
return $test_mode ? self::TEST_PRODUCT_ID_KEY : self::LIVE_PRODUCT_ID_KEY;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the name of the price id option meta, taking test mode into account.
|
||||
*
|
||||
* @param bool|null $test_mode Is WC Pay in test/dev mode.
|
||||
*
|
||||
* @return string The price hash option name.
|
||||
*/
|
||||
public static function get_wcpay_price_id_option( $test_mode = null ): string {
|
||||
$test_mode = null === $test_mode ? WC_Payments::mode()->is_test() : $test_mode;
|
||||
return $test_mode ? self::TEST_PRICE_ID_KEY : self::LIVE_PRICE_ID_KEY;
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets all WCPay Product IDs linked to a WC Product (live and testmode products).
|
||||
*
|
||||
* @param WC_Product $product The product to fetch WCPay product IDs for.
|
||||
*
|
||||
* @return array Live and test WCPay Product IDs if they exist.
|
||||
*/
|
||||
private function get_all_wcpay_product_ids( WC_Product $product ) {
|
||||
$environment_product_ids = [
|
||||
'live' => self::has_wcpay_product_id( $product, false ) ? $this->get_wcpay_product_id( $product, false ) : null,
|
||||
'test' => self::has_wcpay_product_id( $product, true ) ? $this->get_wcpay_product_id( $product, true ) : null,
|
||||
];
|
||||
|
||||
return array_filter( $environment_product_ids );
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns whether the billing cycle is valid, given its period and interval.
|
||||
*
|
||||
* @param string $period Cycle period.
|
||||
* @param int $interval Cycle interval.
|
||||
* @return boolean
|
||||
*/
|
||||
public function is_valid_billing_cycle( $period, $interval ) {
|
||||
$interval_limit = $this->get_period_interval_limit( $period );
|
||||
|
||||
// A cycle is valid when we have a defined limit, and the given interval isn't 0 nor greater than the limit.
|
||||
return $interval_limit && ! empty( $interval ) && $interval <= $interval_limit;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the interval limit for the given period.
|
||||
*
|
||||
* @param string $period The period to get the interval limit for.
|
||||
* @return int|bool The interval limit for the period, or false if not defined.
|
||||
*/
|
||||
private function get_period_interval_limit( $period ) {
|
||||
$max_intervals = [
|
||||
'year' => 1,
|
||||
'month' => 12,
|
||||
'week' => 52,
|
||||
'day' => 365,
|
||||
];
|
||||
|
||||
return ! empty( $max_intervals[ $period ] ) ? $max_intervals[ $period ] : false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Deletes and archives a product WCPay Price IDs.
|
||||
*
|
||||
* @param WC_Product $product The WC Product object to delete and archive the a price IDs.
|
||||
*/
|
||||
private function delete_all_wcpay_price_ids( $product ) {
|
||||
// Delete and archive all price IDs for all environments.
|
||||
foreach ( [ 'test', 'live' ] as $environment ) {
|
||||
$test_mode = 'test' === $environment;
|
||||
$price_id_meta_key = self::get_wcpay_price_id_option( $test_mode );
|
||||
|
||||
if ( $product->meta_exists( $price_id_meta_key ) ) {
|
||||
try {
|
||||
$this->archive_price( $product->get_meta( $price_id_meta_key, true ), $test_mode );
|
||||
} catch ( API_Exception $e ) {
|
||||
Logger::log( 'There was a problem archiving the ' . $environment . 'product price ID in WC Pay: ' . $e->getMessage() );
|
||||
}
|
||||
|
||||
// Now that the price has been archived, delete the record of it.
|
||||
$product->delete_meta_data( $price_id_meta_key );
|
||||
}
|
||||
}
|
||||
|
||||
$product->delete_meta_data( self::PRICE_HASH_KEY );
|
||||
$product->save();
|
||||
}
|
||||
|
||||
/**
|
||||
* Validates that we have the data necessary to create a product in WCPay.
|
||||
*
|
||||
* @param array $product_data Data used to create/update the product in WCPay.
|
||||
* @throws Exception If the product data doesn't contain the 'name' argument as the 'name' property is a required field.
|
||||
*/
|
||||
private function validate_product_data( $product_data ) {
|
||||
if ( empty( $product_data['name'] ) ) {
|
||||
throw new Exception( 'The product "name" is required.' );
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Deprecated functions
|
||||
*/
|
||||
|
||||
/**
|
||||
* Unarchives a WC Pay Price object.
|
||||
*
|
||||
* @deprecated 3.3.0
|
||||
*
|
||||
* @param string $wcpay_price_id The Price object's ID to unarchive.
|
||||
* @param bool|null $test_mode Is WC Pay in test/dev mode.
|
||||
*/
|
||||
public function unarchive_price( string $wcpay_price_id, $test_mode = null ) {
|
||||
wc_deprecated_function( __FUNCTION__, '3.3.0' );
|
||||
$data = [ 'active' => 'true' ];
|
||||
|
||||
if ( null !== $test_mode ) {
|
||||
$data['test_mode'] = $test_mode;
|
||||
}
|
||||
|
||||
$this->payments_api_client->update_price( $wcpay_price_id, $data );
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the WC Pay price hash associated with a WC product.
|
||||
*
|
||||
* @deprecated 3.3.0
|
||||
*
|
||||
* @param WC_Product $product The product to get the hash for.
|
||||
* @return string The product's price hash or an empty string.
|
||||
*/
|
||||
public static function get_wcpay_price_hash( WC_Product $product ): string {
|
||||
wc_deprecated_function( __FUNCTION__, '3.3.0' );
|
||||
return $product->get_meta( self::PRICE_HASH_KEY, true );
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the WC Pay price ID associated with a WC product.
|
||||
*
|
||||
* @deprecated 3.3.0
|
||||
*
|
||||
* @param WC_Product $product The product to get the WC Pay price ID for.
|
||||
* @param bool|null $test_mode Is WC Pay in test/dev mode.
|
||||
*
|
||||
* @return string The product's WC Pay price ID or an empty string.
|
||||
*/
|
||||
public function get_wcpay_price_id( WC_Product $product, $test_mode = null ): string {
|
||||
wc_deprecated_function( __FUNCTION__, '3.3.0' );
|
||||
$price_id = $product->get_meta( self::get_wcpay_price_id_option( $test_mode ), true );
|
||||
|
||||
// If the subscription product doesn't have a WC Pay price ID, create one now.
|
||||
if ( empty( $price_id ) && WC_Subscriptions_Product::is_subscription( $product ) ) {
|
||||
$is_current_environment = null === $test_mode || WC_Payments::mode()->is_test() === $test_mode;
|
||||
|
||||
// Only create WCPay Price object if we're trying to getch a wcpay price ID in the current environment.
|
||||
if ( $is_current_environment ) {
|
||||
WC_Payments_Subscriptions::get_product_service()->create_product( $product );
|
||||
$price_id = $product->get_meta( self::get_wcpay_price_id_option(), true );
|
||||
}
|
||||
}
|
||||
|
||||
return $price_id;
|
||||
}
|
||||
}
|
||||
+224
@@ -0,0 +1,224 @@
|
||||
<?php
|
||||
/**
|
||||
* Class WC_Payments_Subscription_Change_Payment_Method
|
||||
*
|
||||
* @package WooCommerce\Payments
|
||||
*/
|
||||
|
||||
defined( 'ABSPATH' ) || exit;
|
||||
|
||||
/**
|
||||
* Class handling any WCPay subscription change payment method functionality.
|
||||
*/
|
||||
class WC_Payments_Subscription_Change_Payment_Method_Handler {
|
||||
|
||||
/**
|
||||
* Constructor.
|
||||
*/
|
||||
public function __construct() {
|
||||
// Add an "Update card" action to all WCPay billing subscriptions with a failed renewal order.
|
||||
add_filter( 'wcs_view_subscription_actions', [ $this, 'update_subscription_change_payment_button' ], 15, 2 );
|
||||
add_filter( 'woocommerce_can_subscription_be_updated_to_new-payment-method', [ $this, 'can_update_payment_method' ], 15, 2 );
|
||||
|
||||
// Override the pay for order link on the order to redirect to a change payment method page.
|
||||
add_filter( 'woocommerce_my_account_my_orders_actions', [ $this, 'update_order_pay_button' ], 15, 2 );
|
||||
|
||||
// Filter elements/messaging on the "Change payment method" page to reflect updating a WCPay billing card.
|
||||
add_filter( 'woocommerce_subscriptions_change_payment_method_page_title', [ $this, 'change_payment_method_page_title' ], 10, 2 );
|
||||
add_filter( 'woocommerce_subscriptions_change_payment_method_page_notice_message', [ $this, 'change_payment_method_page_notice' ], 10, 2 );
|
||||
|
||||
// Fallback to redirecting all pay for order pages for WCPay billing invoices to the update card page.
|
||||
add_action( 'template_redirect', [ $this, 'redirect_pay_for_order_to_update_payment_method' ] );
|
||||
|
||||
add_filter( 'woocommerce_change_payment_button_text', [ $this, 'change_payment_method_form_submit_text' ] );
|
||||
}
|
||||
|
||||
/**
|
||||
* Replaces the default change payment method action for WC Pay subscriptions when the subscription needs a new payment method after a failed attempt.
|
||||
*
|
||||
* @param array $actions The My Account > View Subscription actions.
|
||||
* @param WC_Subscription $subscription The subscription object.
|
||||
*
|
||||
* @return array The subscription actions.
|
||||
*/
|
||||
public function update_subscription_change_payment_button( $actions, $subscription ) {
|
||||
if ( $this->does_subscription_need_payment_updated( $subscription ) ) {
|
||||
// Override any existing button on $actions['change_payment_method'] to show "Update Card" button.
|
||||
$actions['change_payment_method'] = [
|
||||
'url' => $this->get_subscription_update_payment_url( $subscription ),
|
||||
'name' => __( 'Update payment method', 'woocommerce-payments' ),
|
||||
];
|
||||
}
|
||||
|
||||
return $actions;
|
||||
}
|
||||
|
||||
/**
|
||||
* Updates the 'Pay' link displayed on the My Account > Orders or from a subscriptions related orders table, to make sure customers are directed to update their card.
|
||||
*
|
||||
* @param array $actions Order actions.
|
||||
* @param WC_Order $order The WC Order object.
|
||||
*
|
||||
* @return array The order actions.
|
||||
*/
|
||||
public function update_order_pay_button( $actions, $order ) {
|
||||
// If the order isn't payable, there's nothing to update.
|
||||
if ( ! isset( $actions['pay'] ) ) {
|
||||
return $actions;
|
||||
}
|
||||
|
||||
// If there isn't an invoice linked to this order, there's nothing to update.
|
||||
if ( ! WC_Payments_Invoice_Service::get_order_invoice_id( $order ) ) {
|
||||
return $actions;
|
||||
}
|
||||
|
||||
$subscriptions = wcs_get_subscriptions_for_order( $order, [ 'order_type' => 'any' ] );
|
||||
$subscription = ! empty( $subscriptions ) ? array_pop( $subscriptions ) : null;
|
||||
|
||||
// If we couldn't locate the subscription, we can assume this is a WCPay subscription by the fact the order has an invoice ID.
|
||||
// As a failsafe remove the 'pay' action for this order as that's not the accepted flow for WCPay subscriptions.
|
||||
if ( ! $subscription ) {
|
||||
unset( $actions['pay'] );
|
||||
return $actions;
|
||||
}
|
||||
|
||||
// Only alter the pay action if the subscription needs a new payment method.
|
||||
if ( $this->does_subscription_need_payment_updated( $subscription ) ) {
|
||||
$actions['pay']['url'] = $this->get_subscription_update_payment_url( $subscription );
|
||||
}
|
||||
|
||||
return $actions;
|
||||
}
|
||||
|
||||
/**
|
||||
* Filters subscription `can_be_updated_to( 'new-payment-method' )` calls to allow customers to update their subscription's payment method.
|
||||
*
|
||||
* @param bool $can_update Whether the subscription's payment method can be updated.
|
||||
* @param WC_Subscription $subscription The WC Subscription object.
|
||||
*
|
||||
* @return bool Whether the subscription's payment method can be updated.
|
||||
*/
|
||||
public function can_update_payment_method( bool $can_update, WC_Subscription $subscription ) {
|
||||
return $this->does_subscription_need_payment_updated( $subscription ) ? true : $can_update;
|
||||
}
|
||||
|
||||
/**
|
||||
* Redirects customers to update their payment method rather than pay for a WC Pay Subscription's failed order.
|
||||
*/
|
||||
public function redirect_pay_for_order_to_update_payment_method() {
|
||||
global $wp;
|
||||
|
||||
// Note: There is no nonce verification for the "pay for order" action - the URL is long living.
|
||||
if ( ! isset( $_GET['pay_for_order'], $_GET['key'] ) || ! empty( $_GET['change_payment_method'] ) || ( ! isset( $_GET['order_id'] ) && ! isset( $wp->query_vars['order-pay'] ) ) ) { // phpcs:ignore WordPress.Security.NonceVerification.Recommended
|
||||
return;
|
||||
}
|
||||
|
||||
$order_id = ( isset( $wp->query_vars['order-pay'] ) ) ? absint( $wp->query_vars['order-pay'] ) : absint( $_GET['order_id'] ); // phpcs:ignore WordPress.Security.NonceVerification.Recommended
|
||||
$order_key = wc_clean( wp_unslash( $_GET['key'] ) ); // phpcs:ignore WordPress.Security.NonceVerification.Recommended
|
||||
$order = wc_get_order( $order_id );
|
||||
|
||||
if ( ! $order || ! hash_equals( $order->get_order_key(), $order_key ) || ! current_user_can( 'pay_for_order', $order->get_id() ) ) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Check if the order is linked to a billing invoice.
|
||||
$invoice_id = WC_Payments_Invoice_Service::get_order_invoice_id( $order );
|
||||
|
||||
if ( $invoice_id ) {
|
||||
$subscriptions = wcs_get_subscriptions_for_order( $order, [ 'order_type' => 'any' ] );
|
||||
|
||||
if ( ! empty( $subscriptions ) ) {
|
||||
$subscription = array_pop( $subscriptions );
|
||||
|
||||
if ( $subscription && current_user_can( 'edit_shop_subscription_payment_method', $subscription->get_id() ) && $this->does_subscription_need_payment_updated( $subscription ) ) {
|
||||
wp_safe_redirect( $this->get_subscription_update_payment_url( $subscription ) );
|
||||
exit;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Modifies the change payment method page title (and page breadcrumbs) when updating card details for WC Pay subscriptions.
|
||||
*
|
||||
* @param string $title The default page title.
|
||||
* @param WC_Subscription $subscription The WC Subscription object.
|
||||
*
|
||||
* @return string The page title.
|
||||
*/
|
||||
public function change_payment_method_page_title( string $title, WC_Subscription $subscription ) {
|
||||
if ( $this->does_subscription_need_payment_updated( $subscription ) ) {
|
||||
$title = __( 'Update payment details', 'woocommerce-payments' );
|
||||
}
|
||||
|
||||
return $title;
|
||||
}
|
||||
|
||||
/**
|
||||
* Modifies the message shown on the change payment method page.
|
||||
*
|
||||
* @param string $message The default customer notice shown on the change payment method page.
|
||||
* @param WC_Subscription $subscription The Subscription.
|
||||
*
|
||||
* @return string The customer notice shown on the change payment method page.
|
||||
*/
|
||||
public function change_payment_method_page_notice( string $message, WC_Subscription $subscription ) {
|
||||
if ( $this->does_subscription_need_payment_updated( $subscription ) ) {
|
||||
$message = __( "Your subscription's last renewal failed payment. Please update your payment details so we can reattempt payment.", 'woocommerce-payments' );
|
||||
}
|
||||
|
||||
return $message;
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if a subscription needs to update it's WCPay payment method.
|
||||
*
|
||||
* @param WC_Subscription $subscription The WC Subscription object.
|
||||
* @return bool Whether the subscription's last order failed and needs a new updated payment method.
|
||||
*/
|
||||
private function does_subscription_need_payment_updated( $subscription ) {
|
||||
// We're only interested in WC Pay subscriptions that are on hold due to a failed payment.
|
||||
if ( ! $subscription->has_status( 'on-hold' ) || ! WC_Payments_Subscription_Service::is_wcpay_subscription( $subscription ) ) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$last_order = $subscription->get_last_order( 'all', 'any' );
|
||||
|
||||
return $last_order && $last_order->has_status( 'failed' ) && WC_Payments_Invoice_Service::get_pending_invoice_id( $subscription );
|
||||
}
|
||||
|
||||
/**
|
||||
* Generates the URL for the WC Pay Subscription's update payment method screen.
|
||||
*
|
||||
* @param WC_Subscription $subscription The WC Subscription object.
|
||||
* @return string The update payment method
|
||||
*/
|
||||
private function get_subscription_update_payment_url( $subscription ) {
|
||||
return add_query_arg( // nosemgrep: audit.php.wp.security.xss.query-arg -- no user input is used in this URL.
|
||||
[
|
||||
'change_payment_method' => $subscription->get_id(),
|
||||
'_wpnonce' => wp_create_nonce(),
|
||||
],
|
||||
$subscription->get_checkout_payment_url()
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Modifies the change payment method form submit button to include language about retrying payment if there's a failed order.
|
||||
*
|
||||
* @param string $button_text The change subscription payment method button text.
|
||||
* @return string The change subscription payment method button text.
|
||||
*/
|
||||
public function change_payment_method_form_submit_text( $button_text ) {
|
||||
|
||||
if ( isset( $_GET['change_payment_method'] ) ) { // phpcs:ignore WordPress.Security.NonceVerification
|
||||
$subscription = wcs_get_subscription( wc_clean( wp_unslash( $_GET['change_payment_method'] ) ) ); // phpcs:ignore WordPress.Security.NonceVerification
|
||||
|
||||
if ( $subscription && $this->does_subscription_need_payment_updated( $subscription ) ) {
|
||||
$button_text = __( 'Update and retry payment', 'woocommerce-payments' );
|
||||
}
|
||||
}
|
||||
|
||||
return $button_text;
|
||||
}
|
||||
}
|
||||
+165
@@ -0,0 +1,165 @@
|
||||
<?php
|
||||
/**
|
||||
* Class WC_Payments_Migration_Log_Handler
|
||||
*
|
||||
* @package WooCommerce\Payments
|
||||
*/
|
||||
|
||||
use Automattic\Jetpack\Constants;
|
||||
|
||||
defined( 'ABSPATH' ) || exit;
|
||||
|
||||
/**
|
||||
* Class for handling Stripe billing -> tokenized migration logs.
|
||||
*/
|
||||
class WC_Payments_Subscription_Migration_Log_Handler {
|
||||
|
||||
/**
|
||||
* Log handle (name).
|
||||
*
|
||||
* @var string
|
||||
*/
|
||||
const HANDLE = 'woopayments-subscription-migration';
|
||||
|
||||
/**
|
||||
* A flag temporarily stored in the context column used to identify DB log entries that have been extended to avoid automated deletion.
|
||||
*
|
||||
* @var array
|
||||
*/
|
||||
const EXTENDED_DB_ENTRY_FLAG = 'extended_migration_log';
|
||||
|
||||
/**
|
||||
* The number of years to extend the life of a DB log entry for temporarily.
|
||||
*
|
||||
* @var int
|
||||
*/
|
||||
const DB_ENTRY_EXTENSION_IN_YEARS = 5;
|
||||
|
||||
/**
|
||||
* The holding property for our WC_Logger instance.
|
||||
*
|
||||
* @var WC_Logger
|
||||
*/
|
||||
private $logger = null;
|
||||
|
||||
/**
|
||||
* Constructor.
|
||||
*/
|
||||
public function __construct() {
|
||||
// WC Core will delete logs on priority 10, so we need to run before that.
|
||||
if ( $this->has_file_logger_enabled() ) {
|
||||
add_action( 'woocommerce_cleanup_logs', [ $this, 'extend_life_of_migration_file_logs' ], 5 );
|
||||
} elseif ( $this->has_db_logger_enabled() ) {
|
||||
add_action( 'woocommerce_cleanup_logs', [ $this, 'extend_life_of_migration_db_logs' ], 5 );
|
||||
add_action( 'woocommerce_cleanup_logs', [ $this, 'restore_db_log_timestamps' ], 100 );
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Logs a message to the migration log.
|
||||
*
|
||||
* @param string $message The message to log.
|
||||
*/
|
||||
public function log( $message ) {
|
||||
if ( ! $this->logger ) {
|
||||
$this->logger = wc_get_logger();
|
||||
}
|
||||
|
||||
$this->logger->debug( $message, [ 'source' => self::HANDLE ] );
|
||||
}
|
||||
|
||||
/**
|
||||
* Extends the life of all Stripe billing -> tokenized migration log files to prevent WC Core deleting them.
|
||||
*
|
||||
* WC uses the file's last modified timestamp to determine whether to delete it. This function simply
|
||||
* touches all migration log files to update their last modified timestamp and to bypass WC core's process.
|
||||
*/
|
||||
public function extend_life_of_migration_file_logs() {
|
||||
foreach ( WC_Log_Handler_File::get_log_files() as $log_file_name ) {
|
||||
// If the log file name starts with our handle, "touch" it to update the last modified timestamp.
|
||||
if ( strpos( $log_file_name, self::HANDLE ) === 0 ) {
|
||||
touch( trailingslashit( WC_LOG_DIR ) . $log_file_name ); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_touch
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Extends the life of all Stripe billing -> tokenized migration DB log entries to prevent WC Core deleting them.
|
||||
*
|
||||
* This function temporarily adds 5 years to all migration log entries, and adds a flag to the context column to
|
||||
* identify them for reinstating the actual timestamp later. Hooked in after this function,
|
||||
* `restore_db_log_timestamps` will reinstate the actual timestamps by subtracting the 5 years.
|
||||
*
|
||||
* @see restore_db_log_timestamps()
|
||||
*/
|
||||
public function extend_life_of_migration_db_logs() {
|
||||
global $wpdb;
|
||||
|
||||
$wpdb->query(
|
||||
$wpdb->prepare(
|
||||
"UPDATE {$wpdb->prefix}woocommerce_log
|
||||
SET timestamp = DATE_ADD( timestamp, INTERVAL %d YEAR ), context = %s
|
||||
WHERE source = %s",
|
||||
self::DB_ENTRY_EXTENSION_IN_YEARS,
|
||||
self::EXTENDED_DB_ENTRY_FLAG,
|
||||
self::HANDLE
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Reinstates the actual timestamps of all Stripe billing -> tokenized migration DB log entries.
|
||||
*
|
||||
* This function is hooked in after `extend_life_of_migration_db_logs` to reinstate the actual timestamps
|
||||
* of all migration log entries by subtracting the 5 years that were added to them.
|
||||
*
|
||||
* @see extend_life_of_migration_db_logs()
|
||||
*/
|
||||
public function restore_db_log_timestamps() {
|
||||
global $wpdb;
|
||||
|
||||
$wpdb->query(
|
||||
$wpdb->prepare(
|
||||
"UPDATE {$wpdb->prefix}woocommerce_log
|
||||
SET timestamp = DATE_SUB( timestamp, INTERVAL %d YEAR ), context = NULL
|
||||
WHERE source = %s AND context = %s",
|
||||
self::DB_ENTRY_EXTENSION_IN_YEARS,
|
||||
self::HANDLE,
|
||||
self::EXTENDED_DB_ENTRY_FLAG
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the default log handler class.
|
||||
*
|
||||
* @return string The default log handler class.
|
||||
*/
|
||||
private function get_default_log_handler_class() {
|
||||
$handler_class = Constants::get_constant( 'WC_LOG_HANDLER' );
|
||||
|
||||
if ( is_null( $handler_class ) || ! class_exists( $handler_class ) ) {
|
||||
$handler_class = WC_Log_Handler_File::class;
|
||||
}
|
||||
|
||||
return $handler_class;
|
||||
}
|
||||
|
||||
/**
|
||||
* Determines whether the file logger is enabled.
|
||||
*
|
||||
* @return boolean
|
||||
*/
|
||||
private function has_file_logger_enabled() {
|
||||
return $this->get_default_log_handler_class() === WC_Log_Handler_File::class;
|
||||
}
|
||||
|
||||
/**
|
||||
* Determines whether the DB logger is enabled.
|
||||
*
|
||||
* @return boolean
|
||||
*/
|
||||
private function has_db_logger_enabled() {
|
||||
return $this->get_default_log_handler_class() === WC_Log_Handler_DB::class;
|
||||
}
|
||||
}
|
||||
+77
@@ -0,0 +1,77 @@
|
||||
<?php
|
||||
/**
|
||||
* Class WC_Payments_Subscription_Minimum_Amount_Handler
|
||||
*
|
||||
* @package WooCommerce\Payments
|
||||
*/
|
||||
|
||||
/**
|
||||
* The WC_Payments_Subscription_Minimum_Amount_Handler class
|
||||
*/
|
||||
class WC_Payments_Subscription_Minimum_Amount_Handler {
|
||||
|
||||
use WC_Payments_Subscriptions_Utilities;
|
||||
|
||||
/**
|
||||
* The API client object.
|
||||
*
|
||||
* @var WC_Payments_API_Client
|
||||
*/
|
||||
private $api_client;
|
||||
|
||||
/**
|
||||
* The transient key used to store the minimum amounts for a given currency.
|
||||
*
|
||||
* @const string
|
||||
*/
|
||||
const MINIMUM_RECURRING_AMOUNT_TRANSIENT_KEY = 'wcpay_subscription_minimum_recurring_amounts';
|
||||
|
||||
/**
|
||||
* The length of time in seconds the minimum amount is stored in a transient.
|
||||
*
|
||||
* @const int
|
||||
*/
|
||||
const MINIMUM_RECURRING_AMOUNTS_TRANSIENT_EXPIRATION = DAY_IN_SECONDS;
|
||||
|
||||
/**
|
||||
* Initialize the class.
|
||||
*
|
||||
* @param WC_Payments_API_Client $api_client The API client object.
|
||||
*/
|
||||
public function __construct( WC_Payments_API_Client $api_client ) {
|
||||
$this->api_client = $api_client;
|
||||
|
||||
if ( WC_Payments_Features::should_use_stripe_billing() ) {
|
||||
add_filter( 'woocommerce_subscriptions_minimum_processable_recurring_amount', [ $this, 'get_minimum_recurring_amount' ], 10, 2 );
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the minimum WC Pay Subscription recurring amount that can be transacted in a given currency.
|
||||
*
|
||||
* @param int|bool $minimum_amount The minimum amount that can be processed in recurring transactions. Can be an int (the minimum amount) or false if no minimum exists.
|
||||
* @param string $currency_code The currency to fetch the minimum amount in.
|
||||
*
|
||||
* @return float The minimum recurring amount.
|
||||
*/
|
||||
public function get_minimum_recurring_amount( $minimum_amount, $currency_code ) {
|
||||
$transient_key = self::MINIMUM_RECURRING_AMOUNT_TRANSIENT_KEY . "_$currency_code";
|
||||
// Enforce uppercase.
|
||||
$transient_key = strtoupper( $transient_key );
|
||||
|
||||
// Minimum amount is purposefully immediately overwritten. The calling function passes a default value which we must receive.
|
||||
$minimum_amount = get_transient( $transient_key );
|
||||
|
||||
if ( false === $minimum_amount ) {
|
||||
try {
|
||||
$minimum_amount = $this->api_client->get_currency_minimum_recurring_amount( $currency_code );
|
||||
} catch ( \WCPay\Exceptions\API_Exception $exception ) {
|
||||
// Currency not supported or other API error.
|
||||
$minimum_amount = 0;
|
||||
}
|
||||
set_transient( $transient_key, $minimum_amount, self::MINIMUM_RECURRING_AMOUNTS_TRANSIENT_EXPIRATION );
|
||||
}
|
||||
|
||||
return WC_Payments_Utils::interpret_stripe_amount( (int) $minimum_amount, strtolower( $currency_code ) );
|
||||
}
|
||||
}
|
||||
+1103
File diff suppressed because it is too large
Load Diff
+89
@@ -0,0 +1,89 @@
|
||||
<?php
|
||||
/**
|
||||
* Class WC_Payments_Subscriptions_Empty_State.
|
||||
*
|
||||
* @package WooCommerce\Payments
|
||||
*/
|
||||
|
||||
if ( ! defined( 'ABSPATH' ) ) {
|
||||
exit; // Exit if accessed directly.
|
||||
}
|
||||
|
||||
/**
|
||||
* Class for loading WooPayments Subscription empty state screen.
|
||||
*/
|
||||
class WC_Payments_Subscriptions_Empty_State_Manager {
|
||||
|
||||
use WC_Payments_Subscriptions_Utilities;
|
||||
|
||||
/**
|
||||
* WC_Payments_Account instance to get information about the account.
|
||||
*
|
||||
* @var WC_Payments_Account
|
||||
*/
|
||||
private $account;
|
||||
|
||||
/**
|
||||
* WC_Payments_Subscriptions_Empty_State Constructor
|
||||
*
|
||||
* @param WC_Payments_Account $account Account class instance.
|
||||
*/
|
||||
public function __construct( WC_Payments_Account $account ) {
|
||||
$this->account = $account;
|
||||
|
||||
if ( ! $this->is_subscriptions_plugin_active() ) {
|
||||
add_action( 'admin_enqueue_scripts', [ $this, 'enqueue_scripts_and_styles' ] );
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Enqueues the WCPay Subscription empty state scripts and styles.
|
||||
*/
|
||||
public function enqueue_scripts_and_styles() {
|
||||
$screen = get_current_screen();
|
||||
|
||||
// Only enqueue the scripts on the admin subscriptions screen.
|
||||
if ( ! $screen || 'edit-shop_subscription' !== $screen->id || wcs_do_subscriptions_exist() ) {
|
||||
return;
|
||||
}
|
||||
|
||||
WC_Payments::register_script_with_dependencies( 'WCPAY_SUBSCRIPTIONS_EMPTY_STATE', 'dist/subscriptions-empty-state' );
|
||||
$wcpay_settings = [
|
||||
'connectUrl' => WC_Payments_Account::get_connect_url( 'WC_SUBSCRIPTIONS_TABLE' ),
|
||||
'isConnected' => $this->account->is_stripe_connected(),
|
||||
'newProductUrl' => WC_Subscriptions_Admin::add_subscription_url(),
|
||||
];
|
||||
|
||||
wp_localize_script(
|
||||
'WCPAY_SUBSCRIPTIONS_EMPTY_STATE',
|
||||
'wcpay',
|
||||
$wcpay_settings
|
||||
);
|
||||
|
||||
WC_Payments_Utils::enqueue_style(
|
||||
'WCPAY_SUBSCRIPTIONS_EMPTY_STATE',
|
||||
plugins_url( 'dist/subscriptions-empty-state.css', WCPAY_PLUGIN_FILE ),
|
||||
[],
|
||||
WC_Payments::get_file_version( 'dist/subscriptions-empty-state.css' ),
|
||||
'all'
|
||||
);
|
||||
|
||||
wp_enqueue_script( 'WCPAY_SUBSCRIPTIONS_EMPTY_STATE' );
|
||||
}
|
||||
|
||||
/**
|
||||
* Replaces the default empty subscriptions state HTML with a wrapper for our content to be placed into.
|
||||
*
|
||||
* @deprecated 6.3.0
|
||||
* @param string $default_empty_state_html The default Subscriptions empty state HTML.
|
||||
* @return string The empty subscriptions sate wrapper.
|
||||
*/
|
||||
public function replace_subscriptions_empty_state( $default_empty_state_html ) {
|
||||
wc_deprecated_function( __FUNCTION__, '6.3.0' );
|
||||
if ( wcs_do_subscriptions_exist() ) {
|
||||
return $default_empty_state_html;
|
||||
}
|
||||
|
||||
return '<div id="wcpay_subscriptions_empty_state"></div>';
|
||||
}
|
||||
}
|
||||
+309
@@ -0,0 +1,309 @@
|
||||
<?php
|
||||
/**
|
||||
* Class WC_Payments_Subscriptions_Event_Handler
|
||||
*
|
||||
* @package WooCommerce\Payments
|
||||
*/
|
||||
|
||||
use WCPay\Logger;
|
||||
use WCPay\Exceptions\Invalid_Webhook_Data_Exception;
|
||||
use WCPay\Exceptions\Order_Not_Found_Exception;
|
||||
|
||||
/**
|
||||
* Subscriptions Event/Webhook Handler class
|
||||
*/
|
||||
class WC_Payments_Subscriptions_Event_Handler {
|
||||
|
||||
/**
|
||||
* Maximum amount of payment retries to handle before cancelling the subscription.
|
||||
*
|
||||
* @var int
|
||||
*/
|
||||
const MAX_RETRIES = 4;
|
||||
|
||||
/**
|
||||
* Invoice Service.
|
||||
*
|
||||
* @var WC_Payments_Invoice_Service
|
||||
*/
|
||||
private $invoice_service;
|
||||
|
||||
/**
|
||||
* Subscription Service.
|
||||
*
|
||||
* @var WC_Payments_Subscription_Service
|
||||
*/
|
||||
private $subscription_service;
|
||||
|
||||
/**
|
||||
* Subscriptions event handler constructor.
|
||||
*
|
||||
* @param WC_Payments_Invoice_Service $invoice_service Invoice service.
|
||||
* @param WC_Payments_Subscription_Service $subscription_service Subscription service.
|
||||
*/
|
||||
public function __construct( WC_Payments_Invoice_Service $invoice_service, WC_Payments_Subscription_Service $subscription_service ) {
|
||||
$this->invoice_service = $invoice_service;
|
||||
$this->subscription_service = $subscription_service;
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate and correct subscription status, date, and lines.
|
||||
*
|
||||
* @param array $body The event body that triggered the webhook.
|
||||
*
|
||||
* @throws Invalid_Webhook_Data_Exception Required parameters not found.
|
||||
*/
|
||||
public function handle_invoice_upcoming( array $body ) {
|
||||
$event_object = $this->get_event_property( $body, [ 'data', 'object' ] );
|
||||
$wcpay_subscription_id = $this->get_event_property( $event_object, 'subscription' );
|
||||
|
||||
/**
|
||||
* When a store is in staging mode, we don't want any webhook handling response to be sent to the server.
|
||||
*
|
||||
* Sending requests from staging sites can have unintended consequences for the live store. For example,
|
||||
* Subscriptions which renew on the staging site will lead to paused subscriptions at Stripe and result in
|
||||
* missed renewal payments.
|
||||
*/
|
||||
if ( WC_Payments_Subscriptions::is_duplicate_site() ) {
|
||||
$this->log_skipped_webhook_due_to_staging( 'invoice.upcoming', $wcpay_subscription_id );
|
||||
return;
|
||||
}
|
||||
|
||||
$wcpay_discounts = $this->get_event_property( $event_object, 'discounts' );
|
||||
$wcpay_lines = $this->get_event_property( $event_object, [ 'lines', 'data' ] );
|
||||
$subscription = WC_Payments_Subscription_Service::get_subscription_from_wcpay_subscription_id( $wcpay_subscription_id );
|
||||
|
||||
if ( ! $subscription ) {
|
||||
throw new Invalid_Webhook_Data_Exception( __( 'Cannot find subscription to handle the "invoice.upcoming" event.', 'woocommerce-payments' ) );
|
||||
}
|
||||
|
||||
$wcpay_subscription = $this->subscription_service->get_wcpay_subscription( $subscription );
|
||||
|
||||
// Suspend or cancel subscription if we didn't expect a next payment.
|
||||
if ( 0 === $subscription->get_time( 'next_payment' ) ) {
|
||||
// TODO: Add error handling to these {cancel/suspend}_subscription calls i.e. add a subscription order note if the WCPay subscription wasn't cancelled.
|
||||
if ( ! $subscription->has_status( 'on-hold' ) && 0 !== $subscription->get_time( 'end' ) ) {
|
||||
$this->subscription_service->cancel_subscription( $subscription );
|
||||
} else {
|
||||
$this->subscription_service->suspend_subscription( $subscription );
|
||||
$subscription->add_order_note( __( 'Suspended WCPay Subscription in invoice.upcoming webhook handler because subscription next_payment date is 0.', 'woocommerce-payments' ) );
|
||||
Logger::log(
|
||||
sprintf(
|
||||
'Suspended WCPay Subscription in invoice.upcoming webhook handler because subscription next_payment date is 0. WC ID: %d; WCPay ID: %s.',
|
||||
$subscription->get_id(),
|
||||
$wcpay_subscription_id
|
||||
)
|
||||
);
|
||||
}
|
||||
} else {
|
||||
// Translators: %s Scheduled/upcoming payment date in Y-m-d H:i:s format.
|
||||
$subscription->add_order_note( sprintf( __( 'Next automatic payment scheduled for %s.', 'woocommerce-payments' ), get_date_from_gmt( gmdate( 'Y-m-d H:i:s', $wcpay_subscription['current_period_end'] ), wc_date_format() . ' ' . wc_time_format() ) ) );
|
||||
|
||||
$this->subscription_service->update_dates_to_match_wcpay_subscription( $wcpay_subscription, $subscription );
|
||||
$this->invoice_service->validate_invoice( $wcpay_lines, $wcpay_discounts ? $wcpay_discounts : [], $subscription );
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Renews a subscription associated with paid invoice.
|
||||
*
|
||||
* @param array $body The event body that triggered the webhook.
|
||||
*
|
||||
* @throws Invalid_Webhook_Data_Exception Required parameters not found.
|
||||
* @throws Order_Not_Found_Exception
|
||||
*/
|
||||
public function handle_invoice_paid( array $body ) {
|
||||
$event_data = $this->get_event_property( $body, 'data' );
|
||||
$event_object = $this->get_event_property( $event_data, 'object' );
|
||||
$wcpay_subscription_id = $this->get_event_property( $event_object, 'subscription' );
|
||||
|
||||
/**
|
||||
* When a store is in staging mode, we don't want any webhook handling response to be sent to the server.
|
||||
*
|
||||
* Sending requests from staging sites can have unintended consequences for the live store. For example,
|
||||
* Subscriptions which renew on the staging site will lead to paused subscriptions at Stripe and result in
|
||||
* missed renewal payments.
|
||||
*/
|
||||
if ( WC_Payments_Subscriptions::is_duplicate_site() ) {
|
||||
$this->log_skipped_webhook_due_to_staging( 'invoice.paid', $wcpay_subscription_id );
|
||||
return;
|
||||
}
|
||||
|
||||
$wcpay_invoice_id = $this->get_event_property( $event_object, 'id' );
|
||||
$subscription = WC_Payments_Subscription_Service::get_subscription_from_wcpay_subscription_id( $wcpay_subscription_id );
|
||||
|
||||
if ( ! $subscription ) {
|
||||
throw new Invalid_Webhook_Data_Exception( __( 'Cannot find subscription for the incoming "invoice.paid" event.', 'woocommerce-payments' ) );
|
||||
}
|
||||
|
||||
// This incoming invoice.paid event is linked to the subscription parent invoice and can be ignored.
|
||||
if ( WC_Payments_Invoice_Service::get_subscription_invoice_id( $subscription ) === $wcpay_invoice_id ) {
|
||||
return;
|
||||
}
|
||||
|
||||
$order = wc_get_order( WC_Payments_Invoice_Service::get_order_id_by_invoice_id( $wcpay_invoice_id ) );
|
||||
|
||||
if ( ! $order ) {
|
||||
$order = wcs_create_renewal_order( $subscription );
|
||||
|
||||
if ( is_wp_error( $order ) ) {
|
||||
throw new Invalid_Webhook_Data_Exception( __( 'Unable to generate renewal order for subscription on the "invoice.paid" event.', 'woocommerce-payments' ) );
|
||||
} else {
|
||||
$order->set_payment_method( WC_Payment_Gateway_WCPay::GATEWAY_ID );
|
||||
$this->invoice_service->set_order_invoice_id( $order, $wcpay_invoice_id );
|
||||
}
|
||||
}
|
||||
|
||||
if ( $order->needs_payment() ) {
|
||||
/*
|
||||
* Temporarily place the subscription on-hold to imitate the normal subscription renewal flow.
|
||||
* This ensures the downstream effects take place, e.g. a payment status order note is added and the
|
||||
* 'woocommerce_subscription_payment_complete' action is fired.
|
||||
*/
|
||||
remove_action( 'woocommerce_subscription_status_on-hold', [ $this->subscription_service, 'handle_subscription_status_on_hold' ] );
|
||||
$subscription->update_status( 'on-hold' );
|
||||
add_action( 'woocommerce_subscription_status_on-hold', [ $this->subscription_service, 'handle_subscription_status_on_hold' ] );
|
||||
|
||||
/*
|
||||
* Remove the reactivate_subscription callback that occurs when a subscription transitions from on-hold to active.
|
||||
* The WCPay subscription will remain active throughout this process and does not need to be reactivated.
|
||||
*/
|
||||
remove_action( 'woocommerce_subscription_status_on-hold_to_active', [ $this->subscription_service, 'reactivate_subscription' ] );
|
||||
$order->payment_complete();
|
||||
add_action( 'woocommerce_subscription_status_on-hold_to_active', [ $this->subscription_service, 'reactivate_subscription' ] );
|
||||
|
||||
/**
|
||||
* Fetch a new instance of the subscription.
|
||||
*
|
||||
* After marking the order as paid, a parallel instance of the subscription would have been reactivated.
|
||||
* To avoid race conditions and cache pollution, fetch a new instance to ensure our current instance doesn't override the active subscription status.
|
||||
*/
|
||||
$subscription = wcs_get_subscription( $subscription->get_id() );
|
||||
}
|
||||
|
||||
if ( isset( $event_object['payment_intent'] ) ) {
|
||||
// Add the payment intent data to the order.
|
||||
$this->invoice_service->get_and_attach_intent_info_to_order( $order, $event_object['payment_intent'] );
|
||||
}
|
||||
|
||||
// Remove pending invoice ID in case one was recorded for previous failed renewal attempts.
|
||||
$this->invoice_service->mark_pending_invoice_paid_for_subscription( $subscription );
|
||||
|
||||
// Record the store's Stripe Billing environment context on the payment intent.
|
||||
$invoice = $this->invoice_service->record_subscription_payment_context( $wcpay_invoice_id );
|
||||
|
||||
// Update charge and transaction metadata - add order id for Stripe Billing.
|
||||
$this->invoice_service->update_charge_details( $invoice, $order->get_id() );
|
||||
|
||||
// Update transaction customer details for Stripe Billing.
|
||||
$this->invoice_service->update_transaction_details( $invoice, $order );
|
||||
}
|
||||
|
||||
/**
|
||||
* Marks a subscription payment associated with invoice as failed.
|
||||
*
|
||||
* @param array $body The event body that triggered the webhook.
|
||||
*
|
||||
* @throws Invalid_Webhook_Data_Exception Required parameters not found.
|
||||
*/
|
||||
public function handle_invoice_payment_failed( array $body ) {
|
||||
$event_data = $this->get_event_property( $body, 'data' );
|
||||
$event_object = $this->get_event_property( $event_data, 'object' );
|
||||
$wcpay_subscription_id = $this->get_event_property( $event_object, 'subscription' );
|
||||
|
||||
/**
|
||||
* When a store is in staging mode, we don't want any webhook handling response to be sent to the server.
|
||||
*
|
||||
* Sending requests from staging sites can have unintended consequences for the live store. For example,
|
||||
* Subscriptions which renew on the staging site will lead to paused subscriptions at Stripe and result in
|
||||
* missed renewal payments.
|
||||
*/
|
||||
if ( WC_Payments_Subscriptions::is_duplicate_site() ) {
|
||||
$this->log_skipped_webhook_due_to_staging( 'invoice.payment_failed', $wcpay_subscription_id );
|
||||
return;
|
||||
}
|
||||
|
||||
$wcpay_invoice_id = $this->get_event_property( $event_object, 'id' );
|
||||
$attempts = (int) $this->get_event_property( $event_object, 'attempt_count' );
|
||||
$subscription = WC_Payments_Subscription_Service::get_subscription_from_wcpay_subscription_id( $wcpay_subscription_id );
|
||||
|
||||
if ( ! $subscription ) {
|
||||
throw new Invalid_Webhook_Data_Exception( __( 'Cannot find subscription for the incoming "invoice.payment_failed" event.', 'woocommerce-payments' ) );
|
||||
}
|
||||
|
||||
$order = wc_get_order( WC_Payments_Invoice_Service::get_order_id_by_invoice_id( $wcpay_invoice_id ) );
|
||||
|
||||
if ( ! $order ) {
|
||||
$order = wcs_create_renewal_order( $subscription );
|
||||
|
||||
if ( is_wp_error( $order ) ) {
|
||||
throw new Invalid_Webhook_Data_Exception( __( 'Unable to generate renewal order for subscription to record the incoming "invoice.payment_failed" event.', 'woocommerce-payments' ) );
|
||||
} else {
|
||||
$order->set_payment_method( WC_Payment_Gateway_WCPay::GATEWAY_ID );
|
||||
$this->invoice_service->set_order_invoice_id( $order, $wcpay_invoice_id );
|
||||
}
|
||||
}
|
||||
|
||||
// Translators: %d Number of failed renewal attempts.
|
||||
$subscription->add_order_note( sprintf( _n( 'WCPay subscription renewal attempt %d failed.', 'WCPay subscription renewal attempt %d failed.', $attempts, 'woocommerce-payments' ), $attempts ) );
|
||||
|
||||
if ( self::MAX_RETRIES > $attempts ) {
|
||||
remove_action( 'woocommerce_subscription_status_on-hold', [ $this->subscription_service, 'handle_subscription_status_on_hold' ] );
|
||||
$subscription->payment_failed();
|
||||
add_action( 'woocommerce_subscription_status_on-hold', [ $this->subscription_service, 'handle_subscription_status_on_hold' ] );
|
||||
} else {
|
||||
$subscription->payment_failed( 'cancelled' );
|
||||
}
|
||||
|
||||
// Record invoice ID so we can trigger repayment on payment method update.
|
||||
$this->invoice_service->mark_pending_invoice_for_subscription( $subscription, $wcpay_invoice_id );
|
||||
|
||||
// Record the store's Stripe Billing environment context on the payment intent.
|
||||
$this->invoice_service->record_subscription_payment_context( $wcpay_invoice_id );
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the event data by property.
|
||||
*
|
||||
* @param array $event_data Event data.
|
||||
* @param mixed $key Requested key.
|
||||
*
|
||||
* @return mixed
|
||||
*
|
||||
* @throws Invalid_Webhook_Data_Exception Event data not found by key.
|
||||
*/
|
||||
private function get_event_property( array $event_data, $key ) {
|
||||
$keys = is_array( $key ) ? $key : [ $key ];
|
||||
$data = $event_data;
|
||||
|
||||
foreach ( $keys as $k ) {
|
||||
if ( ! isset( $data[ $k ] ) ) {
|
||||
// Translators: %s Property name not found in event data array.
|
||||
throw new Invalid_Webhook_Data_Exception( sprintf( __( '%s not found in array', 'woocommerce-payments' ), $k ) );
|
||||
}
|
||||
|
||||
$data = $data[ $k ];
|
||||
}
|
||||
|
||||
return $data;
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a log entry noting that a subscription-related webhook has been skipped due to the current site being in staging mode.
|
||||
*
|
||||
* @param string $event The webhook event type. eg "invoice.paid".
|
||||
* @param string $wcpay_subscription_id The WCPay subsciption ID.
|
||||
*/
|
||||
private function log_skipped_webhook_due_to_staging( string $event, string $wcpay_subscription_id ) {
|
||||
Logger::info(
|
||||
sprintf(
|
||||
// Example message: "invoice.paid webhook processing for sub_abc123defg456 was skipped. The current site (https://staging.example.com) is in staging mode. Live site is https://example.com.
|
||||
'%s webhook processing for %s was skipped. The current site (%s) is in staging mode. Live site is %s.',
|
||||
$event,
|
||||
$wcpay_subscription_id,
|
||||
WCS_Staging::get_site_url_from_source( 'current_wp_site' ),
|
||||
WCS_Staging::get_site_url_from_source( 'subscriptions_install' )
|
||||
)
|
||||
);
|
||||
}
|
||||
}
|
||||
+784
@@ -0,0 +1,784 @@
|
||||
<?php
|
||||
/**
|
||||
* Class WC_Payments_Subscriptions_Migrator
|
||||
*
|
||||
* @package WooCommerce\Payments
|
||||
*/
|
||||
|
||||
use WCPay\Exceptions\API_Exception;
|
||||
|
||||
require_once __DIR__ . '/class-wc-payments-subscription-migration-log-handler.php';
|
||||
|
||||
/**
|
||||
* Handles migrating WCPay Subscriptions to tokenized subscriptions.
|
||||
*
|
||||
* This class extends the WCS_Background_Repairer for scheduling and running the individual migration actions.
|
||||
*/
|
||||
class WC_Payments_Subscriptions_Migrator extends WCS_Background_Repairer {
|
||||
|
||||
/**
|
||||
* Valid subscription statuses to cancel a subscription at Stripe.
|
||||
*
|
||||
* @var array $active_statuses
|
||||
*/
|
||||
private $active_statuses = [ 'active', 'past_due', 'trialing', 'paused' ];
|
||||
|
||||
/**
|
||||
* WCPay Subscription meta keys for migrated data.
|
||||
*
|
||||
* @var array $migrated_meta_keys
|
||||
*/
|
||||
private $meta_keys_to_migrate = [
|
||||
WC_Payments_Subscription_Service::SUBSCRIPTION_ID_META_KEY,
|
||||
WC_Payments_Invoice_Service::ORDER_INVOICE_ID_KEY,
|
||||
WC_Payments_Invoice_Service::PENDING_INVOICE_ID_KEY,
|
||||
WC_Payments_Subscription_Service::SUBSCRIPTION_DISCOUNT_IDS_META_KEY,
|
||||
];
|
||||
|
||||
/**
|
||||
* WC_Payments_API_Client instance.
|
||||
*
|
||||
* @var WC_Payments_API_Client
|
||||
*/
|
||||
private $api_client;
|
||||
|
||||
/**
|
||||
* WC_Payments_Token_Service instance.
|
||||
*
|
||||
* @var WC_Payments_Token_Service
|
||||
*/
|
||||
private $token_service;
|
||||
|
||||
/**
|
||||
* WC_Payments_Subscription_Migration_Log_Handler instance.
|
||||
*
|
||||
* @var WC_Payments_Subscription_Migration_Log_Handler
|
||||
*/
|
||||
protected $logger;
|
||||
|
||||
/**
|
||||
* The Action Scheduler hook used to find and schedule individual migrations of WCPay Subscriptions.
|
||||
*
|
||||
* @var string
|
||||
*/
|
||||
public $scheduled_hook = 'wcpay_schedule_subscription_migrations';
|
||||
|
||||
/**
|
||||
* The Action Scheduler hook to migrate a WCPay Subscription.
|
||||
*
|
||||
* @var string
|
||||
*/
|
||||
public $migrate_hook = 'wcpay_migrate_subscription';
|
||||
|
||||
/**
|
||||
* The option name used to store a batch identifier for the current migration batch.
|
||||
*
|
||||
* @var string
|
||||
*/
|
||||
private $migration_batch_identifier_option = 'wcpay_subscription_migration_batch';
|
||||
|
||||
/**
|
||||
* Constructor.
|
||||
*
|
||||
* @param WC_Payments_API_Client|null $api_client WC_Payments_API_Client instance.
|
||||
* @param WC_Payments_Token_Service|null $token_service WC_Payments_Token_Service instance.
|
||||
*/
|
||||
public function __construct( $api_client = null, $token_service = null ) {
|
||||
$this->api_client = $api_client;
|
||||
$this->token_service = $token_service;
|
||||
$this->logger = new WC_Payments_Subscription_Migration_Log_Handler();
|
||||
|
||||
// Don't copy migrated subscription meta keys to related orders.
|
||||
add_filter( 'wc_subscriptions_object_data', [ $this, 'exclude_migrated_meta' ], 10, 1 );
|
||||
|
||||
// Add manual migration tool to WooCommerce > Status > Tools.
|
||||
add_filter( 'woocommerce_debug_tools', [ $this, 'add_manual_migration_tool' ] );
|
||||
|
||||
// Schedule the single migration action with two args. This is needed because the WCS_Background_Repairer parent class only hooks on with one arg.
|
||||
add_action( $this->migrate_hook . '_retry', [ $this, 'migrate_wcpay_subscription' ], 10, 2 );
|
||||
|
||||
$this->init();
|
||||
}
|
||||
|
||||
/**
|
||||
* Migrates a WCPay Subscription to a tokenized WooPayments subscription powered by WC Subscriptions
|
||||
*
|
||||
* Migration process:
|
||||
* 1. Validate the request to migrate subscription
|
||||
* 2. Fetches the subscription from Stripe
|
||||
* 3. Cancels the subscription at Stripe if it is active
|
||||
* 4. Update the subscription meta to indicate that it has been migrated
|
||||
* 5. Add an order note on the subscription
|
||||
*
|
||||
* @param int $subscription_id The ID of the subscription to migrate.
|
||||
* @param int $attempt The number of times migration has been attempted.
|
||||
*/
|
||||
public function migrate_wcpay_subscription( $subscription_id, $attempt = 0 ) {
|
||||
try {
|
||||
add_action( 'action_scheduler_unexpected_shutdown', [ $this, 'handle_unexpected_shutdown' ], 10, 2 );
|
||||
add_action( 'action_scheduler_failed_execution', [ $this, 'handle_unexpected_action_failure' ], 10, 2 );
|
||||
|
||||
$this->logger->log( sprintf( 'Migrating subscription #%1$d.%2$s', $subscription_id, ( $attempt > 0 ? ' Attempt: ' . ( (int) $attempt + 1 ) : '' ) ) );
|
||||
|
||||
$subscription = $this->validate_subscription_to_migrate( $subscription_id );
|
||||
$wcpay_subscription = $this->fetch_wcpay_subscription( $subscription );
|
||||
|
||||
$this->maybe_cancel_wcpay_subscription( $wcpay_subscription );
|
||||
|
||||
if ( $subscription->has_status( 'active' ) ) {
|
||||
$this->update_next_payment_date( $subscription, $wcpay_subscription );
|
||||
}
|
||||
|
||||
// If the subscription is active or on-hold, verify the payment method is valid and set correctly that it continues to renew.
|
||||
if ( $subscription->has_status( [ 'active', 'on-hold' ] ) ) {
|
||||
$this->verify_subscription_payment_token( $subscription, $wcpay_subscription );
|
||||
}
|
||||
|
||||
$this->update_wcpay_subscription_meta( $subscription );
|
||||
|
||||
if ( WC_Payment_Gateway_WCPay::GATEWAY_ID === $subscription->get_payment_method() ) {
|
||||
$subscription->add_order_note( __( 'This subscription has been successfully migrated to a WooPayments tokenized subscription.', 'woocommerce-payments' ) );
|
||||
}
|
||||
|
||||
$this->logger->log( sprintf( '---- Subscription #%d migration complete.', $subscription_id ) );
|
||||
} catch ( \Exception $e ) {
|
||||
$this->logger->log( $e->getMessage() );
|
||||
|
||||
$this->maybe_reschedule_migration( $subscription_id, $attempt, $e );
|
||||
}
|
||||
|
||||
remove_action( 'action_scheduler_unexpected_shutdown', [ $this, 'handle_unexpected_shutdown' ] );
|
||||
remove_action( 'action_scheduler_failed_execution', [ $this, 'handle_unexpected_action_failure' ] );
|
||||
}
|
||||
|
||||
/**
|
||||
* Validates the request to migrate a WCPay Subscription.
|
||||
*
|
||||
* Only allows migration if:
|
||||
* - The WooCommerce Subscription extension is active
|
||||
* - Store is not in staging mode or is a duplicate site
|
||||
* - The subscription ID is a valid subscription
|
||||
* - The subscription has not already been migrated
|
||||
*
|
||||
* @param int $subscription_id The ID of the subscription to migrate.
|
||||
*
|
||||
* @throws \Exception Skip the migration if the request is invalid.
|
||||
*/
|
||||
private function validate_subscription_to_migrate( $subscription_id ) {
|
||||
if ( ! class_exists( 'WC_Subscriptions' ) ) {
|
||||
throw new \Exception( sprintf( '---- Skipping migration of subscription #%d. The WooCommerce Subscriptions extension is not active.', $subscription_id ) );
|
||||
}
|
||||
|
||||
if ( WC_Payments_Subscriptions::is_duplicate_site() ) {
|
||||
throw new \Exception( sprintf( '---- Skipping migration of subscription #%d. Site is in staging mode.', $subscription_id ) );
|
||||
}
|
||||
|
||||
$subscription = wcs_get_subscription( $subscription_id );
|
||||
|
||||
if ( ! $subscription ) {
|
||||
throw new \Exception( sprintf( '---- Skipping migration of subscription #%d. Subscription not found.', $subscription_id ) );
|
||||
}
|
||||
|
||||
$migrated_wcpay_subscription_id = $subscription->get_meta( '_migrated_wcpay_subscription_id', true );
|
||||
|
||||
if ( ! empty( $migrated_wcpay_subscription_id ) ) {
|
||||
throw new \Exception( sprintf( '---- Skipping migration of subscription #%1$d (%2$s). Subscription has already been migrated.', $subscription_id, $migrated_wcpay_subscription_id ) );
|
||||
}
|
||||
|
||||
return $subscription;
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetches the subscription from Stripe and verifies it has a valid ID and status.
|
||||
*
|
||||
* Returns false if the request returns an unexpected result.
|
||||
*
|
||||
* @param WC_Subscription $subscription The WC subscription to migrate.
|
||||
*
|
||||
* @return array
|
||||
*
|
||||
* @throws \Exception If there's an error fetching the subscription from Stripe.
|
||||
*/
|
||||
private function fetch_wcpay_subscription( $subscription ) {
|
||||
$wcpay_subscription_id = WC_Payments_Subscription_Service::get_wcpay_subscription_id( $subscription );
|
||||
|
||||
if ( ! $wcpay_subscription_id ) {
|
||||
throw new \Exception( sprintf( '---- Skipping migration of subscription #%d. Subscription is not a WCPay Subscription.', $subscription->get_id() ) );
|
||||
}
|
||||
|
||||
try {
|
||||
// Fetch the subscription from Stripe.
|
||||
$wcpay_subscription = $this->api_client->get_subscription( $wcpay_subscription_id );
|
||||
} catch ( API_Exception $e ) {
|
||||
throw new \Exception( sprintf( '---- ERROR: Failed to fetch subscription #%1$d (%2$s) from Stripe. %3$s', $subscription->get_id(), $wcpay_subscription_id, $e->getMessage() ) );
|
||||
}
|
||||
|
||||
if ( empty( $wcpay_subscription['id'] ) || empty( $wcpay_subscription['status'] ) ) {
|
||||
throw new \Exception( sprintf( '---- ERROR: Cannot migrate subscription #%1$d (%2$s). Invalid data fetched from Stripe: %3$s', $subscription->get_id(), $wcpay_subscription_id, var_export( $wcpay_subscription, true ) ) ); // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_var_export
|
||||
}
|
||||
|
||||
return $wcpay_subscription;
|
||||
}
|
||||
|
||||
/**
|
||||
* Cancels the subscription at Stripe if it is active.
|
||||
*
|
||||
* This function checks the status on the subscription at Stripe then cancels it if it's a valid status and logs any errors.
|
||||
*
|
||||
* We skip canceling any subscriptions at Stripe that are:
|
||||
* - incomplete: the subscription was created but no payment method was added to the subscription
|
||||
* - incomplete_expired: the incomplete subscription expired after 24hrs of no payment method being added.
|
||||
* - canceled: the subscription is already canceled
|
||||
* - unpaid: this status is not used by subscriptions in WooCommerce Payments
|
||||
*
|
||||
* @param array $wcpay_subscription The subscription data from Stripe.
|
||||
*
|
||||
* @throws \Exception If there's an error canceling the subscription at Stripe.
|
||||
*/
|
||||
private function maybe_cancel_wcpay_subscription( $wcpay_subscription ) {
|
||||
// Valid statuses to cancel subscription at Stripe: active, past_due, trialing, paused.
|
||||
if ( in_array( $wcpay_subscription['status'], $this->active_statuses, true ) ) {
|
||||
$this->logger->log( sprintf( '---- Stripe subscription (%1$s) has "%2$s" status. Canceling the subscription.', $wcpay_subscription['id'], $this->get_wcpay_subscription_status( $wcpay_subscription ) ) );
|
||||
|
||||
try {
|
||||
// Cancel the subscription in Stripe.
|
||||
$wcpay_subscription = $this->api_client->cancel_subscription( $wcpay_subscription['id'] );
|
||||
} catch ( API_Exception $e ) {
|
||||
throw new \Exception( sprintf( '---- ERROR: Failed to cancel the Stripe subscription (%1$s). %2$s', $wcpay_subscription['id'], $e->getMessage() ) );
|
||||
}
|
||||
|
||||
$this->logger->log( sprintf( '---- Stripe subscription (%1$s) successfully canceled.', $wcpay_subscription['id'] ) );
|
||||
} else {
|
||||
// Statuses that don't need to be canceled: incomplete, incomplete_expired, canceled, unpaid.
|
||||
$this->logger->log( sprintf( '---- Stripe subscription (%1$s) has "%2$s" status. Skipping canceling the subscription at Stripe.', $wcpay_subscription['id'], $this->get_wcpay_subscription_status( $wcpay_subscription ) ) );
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Migrates WCPay Subscription related metadata to a new key prefixed with `_migrated` and deletes the old meta.
|
||||
*
|
||||
* @param WC_Subscription $subscription The subscription with wcpay meta saved.
|
||||
*/
|
||||
private function update_wcpay_subscription_meta( $subscription ) {
|
||||
$updated = false;
|
||||
|
||||
/**
|
||||
* If this subscription is being migrated while scheduling individual actions is on-going, make sure we store meta on the subscription
|
||||
* so that it's still returned by the query in @see get_items_to_repair() to not affect the limit and pagination.
|
||||
*/
|
||||
$migration_start = get_option( $this->migration_batch_identifier_option, 0 );
|
||||
|
||||
if ( 0 !== $migration_start ) {
|
||||
$subscription->update_meta_data( '_wcpay_subscription_migrated_during', $migration_start );
|
||||
$updated = true;
|
||||
}
|
||||
|
||||
foreach ( $this->meta_keys_to_migrate as $meta_key ) {
|
||||
if ( $subscription->meta_exists( $meta_key ) ) {
|
||||
$subscription->update_meta_data( '_migrated' . $meta_key, $subscription->get_meta( $meta_key, true ) );
|
||||
$subscription->delete_meta_data( $meta_key );
|
||||
|
||||
$updated = true;
|
||||
}
|
||||
}
|
||||
|
||||
if ( $updated ) {
|
||||
$subscription->save();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Updates the subscription's next payment date in WooCommerce to ensure a smooth transition to on-site billing.
|
||||
*
|
||||
* There's a scenario where a WCPay subscription is active but has no pending renewal scheduled action.
|
||||
* Once migrated, this results in an active subscription that will remain active forever, without processing a renewal order.
|
||||
*
|
||||
* To ensure that all migrated subscriptions have a pending scheduled action, we need to reschedule the next payment date by
|
||||
* updating the date on the subscription.
|
||||
*
|
||||
* In priority order the new next payment date will be:
|
||||
* - The existing WooCommerce next payment date if it's in the future.
|
||||
* - The Stripe subscription's current_period_end if it's in the future.
|
||||
* - A newly calculated next payment date using the WC_Subscription::calculate_date() method.
|
||||
*
|
||||
* @param WC_Subscription $subscription The WC Subscription being migrated.
|
||||
* @param array $wcpay_subscription The subscription data from Stripe.
|
||||
*/
|
||||
private function update_next_payment_date( $subscription, $wcpay_subscription ) {
|
||||
try {
|
||||
// Just update the existing WC Subscription's next payment date if it's in the future.
|
||||
if ( $subscription->get_time( 'next_payment' ) > time() ) {
|
||||
$new_next_payment = gmdate( 'Y-m-d H:i:s', $subscription->get_time( 'next_payment' ) + 1 );
|
||||
|
||||
$subscription->update_dates( [ 'next_payment' => $new_next_payment ] );
|
||||
$this->logger->log( sprintf( '---- Next payment date updated to %1$s to ensure subscription #%2$d has a pending scheduled payment.', $new_next_payment, $subscription->get_id() ) );
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
// If the subscription was still using WooPayments, use the Stripe subscription's next payment time (current_period_end) if it's in the future.
|
||||
if ( WC_Payment_Gateway_WCPay::GATEWAY_ID === $subscription->get_payment_method() && isset( $wcpay_subscription['current_period_end'] ) && absint( $wcpay_subscription['current_period_end'] ) > time() ) {
|
||||
$new_next_payment = gmdate( 'Y-m-d H:i:s', absint( $wcpay_subscription['current_period_end'] ) );
|
||||
|
||||
$subscription->update_dates( [ 'next_payment' => $new_next_payment ] );
|
||||
$this->logger->log( sprintf( '---- Next payment date updated to %1$s to match Stripe subscription record and to ensure subscription #%2$d has a pending scheduled payment.', $new_next_payment, $subscription->get_id() ) );
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
// Lastly calculate the next payment date.
|
||||
$new_next_payment = $subscription->calculate_date( 'next_payment' );
|
||||
|
||||
if ( wcs_date_to_time( $new_next_payment ) > time() ) {
|
||||
$subscription->update_dates( [ 'next_payment' => $new_next_payment ] );
|
||||
$this->logger->log( sprintf( '---- Calculated a new next payment date (%1$s) to ensure subscription #%2$d has a pending scheduled payment in the future.', $new_next_payment, $subscription->get_id() ) );
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
// If we got here the next payment date is in the past, the Stripe subscription is missing a "current_period_end" or it's in the past, and calculating a new date also failed. Log an error.
|
||||
$this->logger->log(
|
||||
sprintf(
|
||||
'---- ERROR: Failed to update subscription #%1$d next payment date. Current next payment date (%2$s) is in the past, Stripe "current_period_end" data is invalid (%3$s) and an attempt to calculate a new date also failed (%4$s).',
|
||||
$subscription->get_id(),
|
||||
gmdate( 'Y-m-d H:i:s', $subscription->get_time( 'next_payment' ) ),
|
||||
isset( $wcpay_subscription['current_period_end'] ) ? gmdate( 'Y-m-d H:i:s', absint( $wcpay_subscription['current_period_end'] ) ) : 'no data',
|
||||
$new_next_payment
|
||||
)
|
||||
);
|
||||
} catch ( \Exception $e ) {
|
||||
$this->logger->log( sprintf( '---- ERROR: Failed to update subscription #%1$d next payment date. %2$s', $subscription->get_id(), $e->getMessage() ) );
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the subscription status from the WCPay subscription data for logging purposes.
|
||||
*
|
||||
* If a subscription is on-hold in WC we wouldn't have changed the status of the subscription at Stripe, instead, the
|
||||
* subscription would remain active and set `pause_collection` behavior to `void` so that the subscription is not charged.
|
||||
*
|
||||
* The purpose of this function is to handle the `paused_collection` value when mapping the subscription status at Stripe to
|
||||
* a status for logging.
|
||||
*
|
||||
* @param array $wcpay_subscription The subscription data from Stripe.
|
||||
*
|
||||
* @return string The WCPay subscription status for logging purposes.
|
||||
*/
|
||||
private function get_wcpay_subscription_status( $wcpay_subscription ) {
|
||||
if ( empty( $wcpay_subscription['status'] ) ) {
|
||||
return 'unknown';
|
||||
}
|
||||
|
||||
if ( 'active' === $wcpay_subscription['status'] && ! empty( $wcpay_subscription['pause_collection']['behavior'] ) && 'void' === $wcpay_subscription['pause_collection']['behavior'] ) {
|
||||
return 'paused';
|
||||
}
|
||||
|
||||
return $wcpay_subscription['status'];
|
||||
}
|
||||
|
||||
/**
|
||||
* Verifies the payment token on the subscription matches the default payment method on the WCPay Subscription.
|
||||
*
|
||||
* This function does two things:
|
||||
* 1. If the subscription doesn't have a WooPayments payment token, set it to the default payment method from Stripe Billing.
|
||||
* 2. If the subscription has a token, verify the token matches the token on the Stripe Billing subscription
|
||||
*
|
||||
* @param WC_Subscription $subscription The subscription to verify the payment token on.
|
||||
* @param array $wcpay_subscription The subscription data from Stripe.
|
||||
*/
|
||||
private function verify_subscription_payment_token( $subscription, $wcpay_subscription ) {
|
||||
// If the subscription's payment method isn't set to WooPayments, we skip this token step.
|
||||
if ( $subscription->get_payment_method() !== WC_Payment_Gateway_WCPay::GATEWAY_ID ) {
|
||||
$this->logger->log( sprintf( '---- Skipped verifying the payment token. Subscription #%1$d has "%2$s" as the payment method.', $subscription->get_id(), $subscription->get_payment_method() ) );
|
||||
return;
|
||||
}
|
||||
|
||||
if ( empty( $wcpay_subscription['default_payment_method'] ) ) {
|
||||
$this->logger->log( sprintf( '---- Could not verify the payment method. Stripe Billing subscription (%1$s) does not have a default payment method.', $wcpay_subscription['id'] ?? 'unknown' ) );
|
||||
return;
|
||||
}
|
||||
|
||||
$tokens = $subscription->get_payment_tokens();
|
||||
$token_id = end( $tokens );
|
||||
$token = ! $token_id ? null : WC_Payment_Tokens::get( $token_id );
|
||||
|
||||
// If the token matches the default payment method on the Stripe Billing subscription, we're done here.
|
||||
if ( $token && $token->get_token() === $wcpay_subscription['default_payment_method'] ) {
|
||||
$this->logger->log( sprintf( '---- Payment token on subscription #%1$d matches the payment method on the Stripe Billing subscription (%2$s).', $subscription->get_id(), $wcpay_subscription['id'] ?? 'unknown' ) );
|
||||
return;
|
||||
}
|
||||
|
||||
// At this point we know the subscription doesn't have a token or the token doesn't match, add one using the default payment method on the WCPay Subscription.
|
||||
$new_token = $this->maybe_create_and_update_payment_token( $subscription, $wcpay_subscription );
|
||||
|
||||
if ( $new_token ) {
|
||||
$this->logger->log( sprintf( '---- Payment token on subscription #%1$d has been updated (from %2$s to %3$s) to match the payment method on the Stripe Billing subscription.', $subscription->get_id(), $token ? $token->get_token() : 'missing', $wcpay_subscription['default_payment_method'] ) );
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Locates a payment token or creates one if it doesn't exist, then updates the subscription with the new token.
|
||||
*
|
||||
* @param WC_Subscription $subscription The subscription to add the payment token to.
|
||||
* @param array $wcpay_subscription The subscription data from Stripe.
|
||||
*
|
||||
* @return WC_Payment_Token|false The new payment token or false if the token couldn't be created.
|
||||
*/
|
||||
private function maybe_create_and_update_payment_token( $subscription, $wcpay_subscription ) {
|
||||
$token = false;
|
||||
$user = new WP_User( $subscription->get_user_id() );
|
||||
$customer_tokens = WC_Payment_Tokens::get_tokens(
|
||||
[
|
||||
'user_id' => $user->ID,
|
||||
'gateway_id' => WC_Payment_Gateway_WCPay::GATEWAY_ID,
|
||||
'limit' => WC_Payment_Gateway_WCPay::USER_FORMATTED_TOKENS_LIMIT,
|
||||
]
|
||||
);
|
||||
|
||||
foreach ( $customer_tokens as $customer_token ) {
|
||||
if ( $customer_token->get_token() === $wcpay_subscription['default_payment_method'] ) {
|
||||
$token = $customer_token;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// If we didn't find a token linked to the subscription customer, create one.
|
||||
if ( ! $token ) {
|
||||
try {
|
||||
$token = $this->token_service->add_payment_method_to_user( $wcpay_subscription['default_payment_method'], $user );
|
||||
$this->logger->log( sprintf( '---- Created a new payment token (%1$s) for subscription #%2$d.', $token->get_token(), $subscription->get_id() ) );
|
||||
} catch ( \Exception $e ) {
|
||||
$this->logger->log( sprintf( '---- WARNING: Subscription #%1$d is missing a payment token and we failed to create one. Error: %2$s', $subscription->get_id(), $e->getMessage() ) );
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// Prevent the WC_Payments_Subscriptions class from attempting to update the Stripe Billing subscription's payment method while we set the token.
|
||||
remove_action( 'woocommerce_payment_token_added_to_order', [ WC_Payments_Subscriptions::get_subscription_service(), 'update_wcpay_subscription_payment_method' ], 10 );
|
||||
|
||||
$subscription->add_payment_token( $token );
|
||||
|
||||
// Reattach.
|
||||
add_action( 'woocommerce_payment_token_added_to_order', [ WC_Payments_Subscriptions::get_subscription_service(), 'update_wcpay_subscription_payment_method' ], 10, 3 );
|
||||
|
||||
return $token;
|
||||
}
|
||||
|
||||
/**
|
||||
* Prevents migrated WCPay subscription metadata being copied to subscription related orders (renewal/switch/resubscribe).
|
||||
*
|
||||
* @param array $meta_data The meta data to be copied.
|
||||
* @return array The meta data to be copied.
|
||||
*/
|
||||
public function exclude_migrated_meta( $meta_data ) {
|
||||
foreach ( $this->meta_keys_to_migrate as $key ) {
|
||||
unset( $meta_data[ '_migrated' . $key ] );
|
||||
}
|
||||
|
||||
return $meta_data;
|
||||
}
|
||||
|
||||
/**
|
||||
* Logs any fatal errors that occur while processing a scheduled migrate WCPay Subscription action.
|
||||
*
|
||||
* @param string $action_id The Action Scheduler action ID.
|
||||
* @param array $error The error data.
|
||||
*/
|
||||
public function handle_unexpected_shutdown( $action_id, $error = null ) {
|
||||
$migration_args = $this->get_migration_action_args( $action_id );
|
||||
|
||||
if ( ! isset( $migration_args['migrate_subscription'], $migration_args['attempt'] ) ) {
|
||||
return;
|
||||
}
|
||||
|
||||
if ( ! empty( $error['type'] ) && in_array( $error['type'], [ E_ERROR, E_PARSE, E_COMPILE_ERROR, E_USER_ERROR, E_RECOVERABLE_ERROR ], true ) ) {
|
||||
$this->logger->log( sprintf( '---- ERROR: Unexpected shutdown while migrating subscription #%1$d: %2$s in %3$s on line %4$s.', $migration_args['migrate_subscription'], $error['message'] ?? 'No message', $error['file'] ?? 'no file found', $error['line'] ?? '0' ) );
|
||||
}
|
||||
|
||||
$this->maybe_reschedule_migration( $migration_args['migrate_subscription'], $migration_args['attempt'] );
|
||||
}
|
||||
|
||||
/**
|
||||
* Handles any unexpected failures that occur while processing a single migration action
|
||||
* by logging an error message and rescheduling the action to retry.
|
||||
*
|
||||
* @param string $action_id The Action Scheduler action ID.
|
||||
* @param Exception $exception The exception thrown during action processing.
|
||||
*/
|
||||
public function handle_unexpected_action_failure( $action_id, $exception ) {
|
||||
$migration_args = $this->get_migration_action_args( $action_id );
|
||||
|
||||
if ( ! isset( $migration_args['migrate_subscription'], $migration_args['attempt'] ) ) {
|
||||
return;
|
||||
}
|
||||
|
||||
$this->logger->log( sprintf( '---- ERROR: Unexpected failure while migrating subscription #%1$d: %2$s', $migration_args['migrate_subscription'], $exception->getMessage() ) );
|
||||
$this->maybe_reschedule_migration( $migration_args['migrate_subscription'], $migration_args['attempt'] );
|
||||
}
|
||||
|
||||
/**
|
||||
* Adds a manual migration tool to WooCommerce > Status > Tools.
|
||||
*
|
||||
* This tool is only loaded on stores that have:
|
||||
* - WC Subscriptions extension activated
|
||||
* - Subscriptions with WooPayments feature disabled
|
||||
* - Existing WCPay Subscriptions that can be migrated
|
||||
*
|
||||
* @param array $tools List of WC debug tools.
|
||||
*
|
||||
* @return array List of WC debug tools.
|
||||
*/
|
||||
public function add_manual_migration_tool( $tools ) {
|
||||
if ( WC_Payments_Features::is_wcpay_subscriptions_enabled() || ! class_exists( 'WC_Subscriptions' ) ) {
|
||||
return $tools;
|
||||
}
|
||||
|
||||
// Get number of WCPay Subscriptions that can be migrated.
|
||||
$wcpay_subscriptions_count = $this->get_stripe_billing_subscription_count();
|
||||
|
||||
if ( $wcpay_subscriptions_count < 1 ) {
|
||||
return $tools;
|
||||
}
|
||||
|
||||
// Disable the button if a migration is currently in progress.
|
||||
$disabled = $this->is_migrating();
|
||||
|
||||
$tools['migrate_wcpay_subscriptions'] = [
|
||||
'name' => __( 'Migrate Stripe Billing subscriptions', 'woocommerce-payments' ),
|
||||
'button' => $disabled ? __( 'Migration in progress', 'woocommerce-payments' ) . '…' : __( 'Migrate Subscriptions', 'woocommerce-payments' ),
|
||||
'desc' => sprintf(
|
||||
// translators: %1$s is a new line character and %2$d is the number of subscriptions.
|
||||
__( 'This tool will migrate all Stripe Billing subscriptions to tokenized subscriptions with WooPayments.%1$sNumber of Stripe Billing subscriptions found: %2$d', 'woocommerce-payments' ),
|
||||
'<br>',
|
||||
$wcpay_subscriptions_count,
|
||||
),
|
||||
'callback' => [ $this, 'schedule_migrate_wcpay_subscriptions_action' ],
|
||||
'disabled' => $disabled,
|
||||
'requires_refresh' => true,
|
||||
];
|
||||
|
||||
return $tools;
|
||||
}
|
||||
|
||||
/**
|
||||
* Schedules the initial migration action which signals the start of the migration process.
|
||||
*/
|
||||
public function schedule_migrate_wcpay_subscriptions_action() {
|
||||
if ( as_next_scheduled_action( $this->scheduled_hook ) ) {
|
||||
return;
|
||||
}
|
||||
|
||||
update_option( $this->migration_batch_identifier_option, time() );
|
||||
|
||||
$this->logger->log( 'Started scheduling subscription migrations.' );
|
||||
$this->schedule_repair();
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the subscription ID and number of attempts from the action args.
|
||||
*
|
||||
* @param int $action_id The action ID to get data from.
|
||||
*
|
||||
* @return array
|
||||
*/
|
||||
private function get_migration_action_args( $action_id ) {
|
||||
$action = ActionScheduler_Store::instance()->fetch_action( $action_id );
|
||||
|
||||
if ( ! $action || ( $this->migrate_hook !== $action->get_hook() && $this->migrate_hook . '_retry' !== $action->get_hook() ) ) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$action_args = $action->get_args();
|
||||
|
||||
if ( ! isset( $action_args['migrate_subscription'] ) ) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return array_merge(
|
||||
[
|
||||
'migrate_subscription' => 0,
|
||||
'attempt' => 0,
|
||||
],
|
||||
$action_args
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Reschedules a subscription migration with increasing delays depending on number of attempts.
|
||||
*
|
||||
* After max retries, an exception is thrown if one was passed.
|
||||
*
|
||||
* @param int $subscription_id The ID of the subscription to retry.
|
||||
* @param int $attempt The number of times migration has been attempted.
|
||||
* @param \Exception|null $exception The exception thrown during migration.
|
||||
*
|
||||
* @throws \Exception If max attempts and exception passed is not null.
|
||||
*/
|
||||
public function maybe_reschedule_migration( $subscription_id, $attempt = 0, $exception = null ) {
|
||||
// Number of seconds to wait before retrying the migration, increasing with each attempt up to 7 attempts (12 hours).
|
||||
$retry_schedule = [ 60, 300, 600, 1800, HOUR_IN_SECONDS, 6 * HOUR_IN_SECONDS, 12 * HOUR_IN_SECONDS ];
|
||||
|
||||
// If the exception thrown contains "Skipping migration", don't reschedule the migration.
|
||||
if ( $exception && false !== strpos( $exception->getMessage(), 'Skipping migration' ) ) {
|
||||
return;
|
||||
}
|
||||
|
||||
if ( isset( $retry_schedule[ $attempt ] ) && $attempt < 7 ) {
|
||||
$this->logger->log( sprintf( '---- Rescheduling migration of subscription #%1$d.', $subscription_id ) );
|
||||
|
||||
as_schedule_single_action(
|
||||
gmdate( 'U' ) + $retry_schedule[ $attempt ],
|
||||
$this->migrate_hook . '_retry',
|
||||
[
|
||||
'migrate_subscription' => $subscription_id,
|
||||
'attempt' => $attempt + 1,
|
||||
]
|
||||
);
|
||||
} else {
|
||||
$this->logger->log( sprintf( '---- FAILED: Subscription #%d could not be migrated.', $subscription_id ) );
|
||||
|
||||
if ( $exception ) {
|
||||
// Before throwing the exception, remove the action_scheduler failure hook to prevent the exception being logged again.
|
||||
remove_action( 'action_scheduler_failed_execution', [ $this, 'handle_unexpected_action_failure' ] );
|
||||
|
||||
throw $exception;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Override WCS_Background_Repairer methods.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Initialize class variables and hooks to handle scheduling and running migration hooks in the background.
|
||||
*/
|
||||
public function init() {
|
||||
$this->repair_hook = $this->migrate_hook;
|
||||
|
||||
parent::init();
|
||||
}
|
||||
|
||||
/**
|
||||
* Schedules an individual action to migrate a subscription.
|
||||
*
|
||||
* Overrides the parent class function to make two changes:
|
||||
* 1. Don't schedule an action if one already exists.
|
||||
* 2. Schedules the migration to happen in one minute instead of in one hour.
|
||||
*
|
||||
* @param int $item The ID of the subscription to migrate.
|
||||
*/
|
||||
public function update_item( $item ) {
|
||||
if ( ! as_next_scheduled_action( $this->migrate_hook, [ 'migrate_subscription' => $item ] ) ) {
|
||||
as_schedule_single_action( gmdate( 'U' ) + 60, $this->migrate_hook, [ 'migrate_subscription' => $item ] );
|
||||
}
|
||||
|
||||
unset( $this->items_to_repair[ $item ] );
|
||||
}
|
||||
|
||||
/**
|
||||
* Migrates an individual subscription.
|
||||
*
|
||||
* The repair_item() function is called by the parent class when the individual scheduled action is run.
|
||||
* This acts as a wrapper for the migrate_wcpay_subscription() function.
|
||||
*
|
||||
* @param int $item The ID of the subscription to migrate.
|
||||
*/
|
||||
public function repair_item( $item ) {
|
||||
$this->migrate_wcpay_subscription( $item );
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets a batch of 100 subscriptions to migrate.
|
||||
*
|
||||
* Because this function fetches items in batches using limit and paged query args, we need to make sure
|
||||
* the paging of this query is consistent regardless of whether some subscriptions have been repaired/migrated in between.
|
||||
*
|
||||
* To do this, we use the $this->migration_batch_identifier_option value to identify subscriptions previously returned by
|
||||
* this function that have been migrated so they will still be considered for paging.
|
||||
*
|
||||
* @param int $page The page of results to fetch.
|
||||
*
|
||||
* @return int[] The IDs of the subscriptions to migrate.
|
||||
*/
|
||||
public function get_items_to_repair( $page ) {
|
||||
$items_to_migrate = wcs_get_orders_with_meta_query(
|
||||
[
|
||||
'return' => 'ids',
|
||||
'type' => 'shop_subscription',
|
||||
'limit' => 100,
|
||||
'status' => 'any',
|
||||
'paged' => $page,
|
||||
'order' => 'ASC',
|
||||
'orderby' => 'ID',
|
||||
'meta_query' => [ // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_query
|
||||
'relation' => 'OR',
|
||||
[
|
||||
'key' => WC_Payments_Subscription_Service::SUBSCRIPTION_ID_META_KEY,
|
||||
'compare' => 'EXISTS',
|
||||
],
|
||||
// We need to include subscriptions which have already been migrated as part of this migration group to make
|
||||
// sure correct paging is maintained. As subscriptions are migrated they would migrate the WCPay subscription ID
|
||||
// meta key and therefore fall out of this query's scope - messing with the paging of future queries.
|
||||
// Subscriptions with the `migrated_during` meta aren't expected to be returned by this query, they are included to pad out the earlier pages.
|
||||
[
|
||||
'key' => '_wcpay_subscription_migrated_during',
|
||||
'value' => get_option( $this->migration_batch_identifier_option, 0 ),
|
||||
'compare' => '=',
|
||||
],
|
||||
],
|
||||
]
|
||||
);
|
||||
|
||||
if ( empty( $items_to_migrate ) ) {
|
||||
$this->logger->log( 'Finished scheduling subscription migrations.' );
|
||||
}
|
||||
|
||||
return $items_to_migrate;
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the total number of subscriptions to migrate.
|
||||
*
|
||||
* @return int The total number of subscriptions to migrate.
|
||||
*/
|
||||
public function get_stripe_billing_subscription_count() {
|
||||
$result = wcs_get_orders_with_meta_query(
|
||||
[
|
||||
'status' => 'any',
|
||||
'return' => 'ids',
|
||||
'type' => 'shop_subscription',
|
||||
'limit' => - 1,
|
||||
'meta_query' => [ // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_query
|
||||
[
|
||||
'key' => WC_Payments_Subscription_Service::SUBSCRIPTION_ID_META_KEY,
|
||||
'compare' => 'EXISTS',
|
||||
],
|
||||
],
|
||||
]
|
||||
);
|
||||
|
||||
return is_countable( $result ) ? count( $result ) : 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Determines if a migration is currently in progress.
|
||||
*
|
||||
* A migration is considered to be in progress if the initial migration action or an individual subscription
|
||||
* action (or retry) is scheduled.
|
||||
*
|
||||
* @return bool True if a migration is in progress, false otherwise.
|
||||
*/
|
||||
public function is_migrating() {
|
||||
return (bool) as_next_scheduled_action( $this->scheduled_hook ) || (bool) as_next_scheduled_action( $this->migrate_hook ) || (bool) as_next_scheduled_action( $this->migrate_hook . '_retry' );
|
||||
}
|
||||
|
||||
/**
|
||||
* Runs any actions that need to handle the completion of the migration.
|
||||
*/
|
||||
protected function unschedule_background_updates() {
|
||||
parent::unschedule_background_updates();
|
||||
|
||||
delete_option( $this->migration_batch_identifier_option );
|
||||
}
|
||||
}
|
||||
+262
@@ -0,0 +1,262 @@
|
||||
<?php
|
||||
/**
|
||||
* Class WC_Payments_Subscriptions_Onboarding_Handler
|
||||
*
|
||||
* @package WooCommerce\Payments
|
||||
*/
|
||||
|
||||
defined( 'ABSPATH' ) || exit;
|
||||
|
||||
use WCPay\Tracker;
|
||||
|
||||
/**
|
||||
* A class to handle the onboarding of subscriptions products. Created subscription products will be set to draft until
|
||||
* onboarding is completed.
|
||||
*/
|
||||
class WC_Payments_Subscriptions_Onboarding_Handler {
|
||||
|
||||
use WC_Payments_Subscriptions_Utilities;
|
||||
|
||||
/**
|
||||
* Option for holding an array of product id's to publish post onboarding.
|
||||
*
|
||||
* @const string
|
||||
*/
|
||||
const WCPAY_SUBSCRIPTION_AUTO_PUBLISH_PRODUCTS = 'wcpay_subscription_onboarding_products';
|
||||
|
||||
/**
|
||||
* The account service instance.
|
||||
*
|
||||
* @var WC_Payments_Account
|
||||
*/
|
||||
private $account;
|
||||
|
||||
/**
|
||||
* Constructor
|
||||
*
|
||||
* @param WC_Payments_Account $account account service instance.
|
||||
*/
|
||||
public function __construct( WC_Payments_Account $account ) {
|
||||
// This action is triggered on product save but after other required subscriptions logic is triggered.
|
||||
add_action( 'woocommerce_admin_process_product_object', [ $this, 'product_save' ] );
|
||||
add_action( 'woocommerce_payments_account_refreshed', [ $this, 'account_data_refreshed' ] );
|
||||
add_action( 'admin_enqueue_scripts', [ $this, 'enqueue_modal_scripts_and_styles' ] );
|
||||
add_action( 'admin_enqueue_scripts', [ $this, 'enqueue_toast_script' ] );
|
||||
add_filter( 'woocommerce_subscriptions_admin_pointer_script_parameters', [ $this, 'filter_admin_pointer_script_parameters' ] );
|
||||
|
||||
$this->account = $account;
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets the account service instance reference on the class.
|
||||
*
|
||||
* @param WC_Payments_Account $account account service instance.
|
||||
*/
|
||||
public function set_account( WC_Payments_Account $account ) {
|
||||
$this->account = $account;
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert subscriptions to drafts when using WCPay (without subscriptions) and onboarding is not complete.
|
||||
* This should be triggered just prior to a $product->save() call so no need to call product->save()
|
||||
*
|
||||
* @param WC_Product $product Subscriptions Product.
|
||||
*/
|
||||
public function product_save( WC_Product $product ) {
|
||||
if ( $this->account->is_stripe_connected() ) {
|
||||
return;
|
||||
}
|
||||
|
||||
// If Subscriptions plugin is installed we don't need to do this check.
|
||||
if ( $this->is_subscriptions_plugin_active() ) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Skip products which have already been scheduled or aren't subscriptions.
|
||||
if ( ! WC_Subscriptions_Product::is_subscription( $product ) ) {
|
||||
return;
|
||||
}
|
||||
|
||||
// We can skip if the post is not yet marked as published.
|
||||
if ( 'publish' !== $product->get_status() ) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Change the default WP saved post URL to correctly reflect the draft status and to add our saved-as-draft flag.
|
||||
add_filter(
|
||||
'redirect_post_location',
|
||||
function () use ( $product ) {
|
||||
return add_query_arg( // nosemgrep: audit.php.wp.security.xss.query-arg -- server generated url is passed in.
|
||||
[
|
||||
'message' => 10, // Post saved as draft message.
|
||||
'wcpay-subscription-saved-as-draft' => 1,
|
||||
],
|
||||
get_edit_post_link( $product->get_id(), 'url' )
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
$this->convert_subscription_to_draft( $product );
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert a product to a draft and save the id in an array, so we can auto-publish once onboarding is complete.
|
||||
*
|
||||
* @param WC_Product $product Product to convert to draft.
|
||||
*/
|
||||
private function convert_subscription_to_draft( WC_Product $product ) {
|
||||
// Force into draft status.
|
||||
$product->set_status( 'draft' );
|
||||
$product->save();
|
||||
|
||||
$auto_publish_ids = get_option( self::WCPAY_SUBSCRIPTION_AUTO_PUBLISH_PRODUCTS, [] );
|
||||
$auto_publish_ids[] = $product->get_id();
|
||||
|
||||
// Save and prevent duplicates from multiple updates.
|
||||
update_option( self::WCPAY_SUBSCRIPTION_AUTO_PUBLISH_PRODUCTS, array_unique( $auto_publish_ids ) );
|
||||
|
||||
Tracker::track_admin( 'wcpay_subscriptions_account_not_connected_save_product' );
|
||||
}
|
||||
|
||||
/**
|
||||
* Method to handle when account data is refreshed and onboarding may have been completed
|
||||
*/
|
||||
public function account_data_refreshed() {
|
||||
|
||||
if ( ! $this->account->is_stripe_connected() ) {
|
||||
return;
|
||||
}
|
||||
|
||||
$products = get_option( self::WCPAY_SUBSCRIPTION_AUTO_PUBLISH_PRODUCTS, [] );
|
||||
|
||||
if ( [] === $products ) {
|
||||
return;
|
||||
}
|
||||
|
||||
foreach ( $products as $product_id ) {
|
||||
$product = wc_get_product( $product_id );
|
||||
if ( ! $product || ! WC_Subscriptions_Product::is_subscription( $product ) ) {
|
||||
continue;
|
||||
}
|
||||
if ( 'draft' !== $product->get_status() ) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$product->set_status( 'publish' );
|
||||
$product->save();
|
||||
}
|
||||
|
||||
// clear auto-published products from option.
|
||||
delete_option( self::WCPAY_SUBSCRIPTION_AUTO_PUBLISH_PRODUCTS );
|
||||
}
|
||||
|
||||
/**
|
||||
* Enqueues the admin scripts needed on the add/edit product screen when the
|
||||
* merchant attempts to publish a subscription product prior to completing
|
||||
* WCPay onboarding.
|
||||
*
|
||||
* @param string $hook_suffix The current admin page.
|
||||
*/
|
||||
public function enqueue_modal_scripts_and_styles( $hook_suffix ) {
|
||||
global $post;
|
||||
|
||||
if ( ! in_array( $hook_suffix, [ 'post.php', 'post-new.php' ], true ) ) {
|
||||
return;
|
||||
}
|
||||
|
||||
if ( ! $post || 'product' !== $post->post_type ) {
|
||||
return;
|
||||
}
|
||||
|
||||
if ( empty( $_GET['wcpay-subscription-saved-as-draft'] ) || 1 !== (int) $_GET['wcpay-subscription-saved-as-draft'] ) { // phpcs:ignore WordPress.Security.NonceVerification.Recommended
|
||||
return;
|
||||
}
|
||||
|
||||
if ( $this->is_subscriptions_plugin_active() ) {
|
||||
return;
|
||||
}
|
||||
|
||||
if ( $this->account->is_stripe_connected() ) {
|
||||
return;
|
||||
}
|
||||
|
||||
WC_Payments::register_script_with_dependencies( 'wcpay-subscription-product-onboarding-modal', 'dist/subscription-product-onboarding-modal' );
|
||||
|
||||
wp_localize_script(
|
||||
'wcpay-subscription-product-onboarding-modal',
|
||||
'wcpaySubscriptionProductOnboardingModal',
|
||||
[
|
||||
'connectUrl' => WC_Payments_Account::get_connect_url( 'WC_SUBSCRIPTIONS_PUBLISH_PRODUCT_' . $post->ID ),
|
||||
'pluginScope' => ( defined( 'WC_VERSION' ) && version_compare( WC_VERSION, '6.5', '>=' ) ) ? 'woocommerce-admin' : 'woocommerce',
|
||||
]
|
||||
);
|
||||
|
||||
WC_Payments_Utils::register_style(
|
||||
'wcpay-subscription-product-onboarding-modal',
|
||||
plugins_url( 'dist/subscription-product-onboarding-modal.css', WCPAY_PLUGIN_FILE ),
|
||||
[],
|
||||
WC_Payments::get_file_version( 'dist/subscription-product-onboarding-modal.css' ),
|
||||
'all'
|
||||
);
|
||||
|
||||
wp_enqueue_script( 'wcpay-subscription-product-onboarding-modal' );
|
||||
wp_enqueue_style( 'wcpay-subscription-product-onboarding-modal' );
|
||||
}
|
||||
|
||||
/**
|
||||
* Enqueues the admin scripts needed on the add/edit product screen when the
|
||||
* merchant has completed WCPay onboarding and is redirected back to product
|
||||
* edit page.
|
||||
*
|
||||
* @param string $hook_suffix The current admin page.
|
||||
*/
|
||||
public function enqueue_toast_script( $hook_suffix ) {
|
||||
global $post;
|
||||
|
||||
if ( ! in_array( $hook_suffix, [ 'post.php', 'post-new.php' ], true ) ) {
|
||||
return;
|
||||
}
|
||||
|
||||
if ( ! $post || 'product' !== $post->post_type ) {
|
||||
return;
|
||||
}
|
||||
|
||||
if ( empty( $_GET['wcpay-subscriptions-onboarded'] ) || 1 !== (int) $_GET['wcpay-subscriptions-onboarded'] ) { // phpcs:ignore WordPress.Security.NonceVerification.Recommended
|
||||
return;
|
||||
}
|
||||
|
||||
if ( $this->is_subscriptions_plugin_active() ) {
|
||||
return;
|
||||
}
|
||||
|
||||
WC_Payments::register_script_with_dependencies( 'wcpay-subscription-product-onboarding-toast', 'dist/subscription-product-onboarding-toast' );
|
||||
|
||||
wp_localize_script(
|
||||
'wcpay-subscription-product-onboarding-toast',
|
||||
'wcpaySubscriptionProductOnboardingToast',
|
||||
[
|
||||
'pluginScope' => ( defined( 'WC_VERSION' ) && version_compare( WC_VERSION, '6.5', '>=' ) ) ? 'woocommerce-admin' : 'woocommerce',
|
||||
]
|
||||
);
|
||||
|
||||
wp_enqueue_script( 'wcpay-subscription-product-onboarding-toast' );
|
||||
}
|
||||
|
||||
/**
|
||||
* Modifies the pointer content found on the "Add new product" page
|
||||
* when WooCommerce Subscriptions is not active.
|
||||
*
|
||||
* @param array $pointer_params Array of strings used on the "Add new product" page.
|
||||
* @return array Potentially modified array of strings used on the "Add new product" page.
|
||||
*/
|
||||
public function filter_admin_pointer_script_parameters( $pointer_params ) {
|
||||
if ( $this->is_subscriptions_plugin_active() ) {
|
||||
return $pointer_params;
|
||||
}
|
||||
|
||||
// translators: %1$s: <h3> tag, %2$s: </h3> tag, %3$s: <p> tag, %4$s: WooPayments, %5$s: <em> tag, %6$s: </em> tag, %7$s: <em> tag, %8$s: </em> tag, %9$s: </p> tag.
|
||||
$pointer_params['typePointerContent'] = sprintf( _x( '%1$sChoose Subscription%2$s%3$s%4$s adds two new subscription product types - %5$sSimple subscription%6$s and %7$sVariable subscription%8$s.%9$s', 'used in admin pointer script params in javascript as type pointer content', 'woocommerce-payments' ), '<h3>', '</h3>', '<p>', 'WooPayments', '<em>', '</em>', '<em>', '</em>', '</p>' );
|
||||
|
||||
return $pointer_params;
|
||||
}
|
||||
}
|
||||
+87
@@ -0,0 +1,87 @@
|
||||
<?php
|
||||
/**
|
||||
* Class WC_Payments_Subscriptions_Plugin_Notice_Manager
|
||||
*
|
||||
* @package WooCommerce\Payments
|
||||
*/
|
||||
|
||||
defined( 'ABSPATH' ) || exit;
|
||||
|
||||
/**
|
||||
* A class to handle the displaying of a warning notice when admin deactivate the WC Subscriptions extension.
|
||||
*/
|
||||
class WC_Payments_Subscriptions_Plugin_Notice_Manager {
|
||||
|
||||
use WC_Payments_Subscriptions_Utilities;
|
||||
|
||||
/**
|
||||
* Initialize the class and attach callbacks.
|
||||
*/
|
||||
public function __construct() {
|
||||
add_action( 'admin_enqueue_scripts', [ $this, 'enqueue_scripts_and_styles' ], 100 );
|
||||
add_action( 'admin_footer', [ $this, 'output_notice_template' ] );
|
||||
}
|
||||
|
||||
/**
|
||||
* Determines if the current screen is the admin plugins screen.
|
||||
*
|
||||
* @return bool Whether the current request is for the admin plugins screen.
|
||||
*/
|
||||
private function is_admin_plugins_screen() {
|
||||
if ( ! is_admin() ) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$screen = get_current_screen();
|
||||
|
||||
return $screen && 'plugins' === $screen->id;
|
||||
}
|
||||
|
||||
/**
|
||||
* Enqueues the admin scripts needed on the plugins screen.
|
||||
*/
|
||||
public function enqueue_scripts_and_styles() {
|
||||
if ( ! $this->is_admin_plugins_screen() || ! WC_Payments_Subscription_Service::store_has_active_wcpay_subscriptions() ) {
|
||||
return;
|
||||
}
|
||||
|
||||
// The backbone modal requires the WC admin styles to be loaded.
|
||||
wp_enqueue_style( 'woocommerce_admin_styles' );
|
||||
|
||||
wp_register_script(
|
||||
'wcpay-subscriptions-plugin',
|
||||
plugins_url( 'includes/subscriptions/assets/js/plugin-page.js', WCPAY_PLUGIN_FILE ),
|
||||
[ 'jquery', 'wc-backbone-modal' ],
|
||||
WCPAY_VERSION_NUMBER,
|
||||
true
|
||||
);
|
||||
|
||||
wp_enqueue_script( 'wcpay-subscriptions-plugin' );
|
||||
|
||||
WC_Payments_Utils::enqueue_style(
|
||||
'wcpay-subscriptions-plugin-styles',
|
||||
plugins_url( 'includes/subscriptions/assets/css/plugin-page.css', WCPAY_PLUGIN_FILE ),
|
||||
[],
|
||||
WCPAY_VERSION_NUMBER,
|
||||
'all'
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Enqueues templates for plugin deactivation warnings on the admin plugin screen.
|
||||
*/
|
||||
public function output_notice_template() {
|
||||
if ( ! $this->is_admin_plugins_screen() ) {
|
||||
return;
|
||||
}
|
||||
|
||||
wc_get_template( 'html-subscriptions-plugin-notice.php', [], '', dirname( __DIR__ ) . '/subscriptions/templates/' );
|
||||
|
||||
// Load a slightly different notice for folks still using the legacy WCPay Subscriptions functionality.
|
||||
if ( WC_Payments::get_gateway()->is_subscriptions_plugin_active() ) {
|
||||
wc_get_template( 'html-woo-payments-deactivate-warning.php', [], '', dirname( __DIR__ ) . '/subscriptions/templates/' );
|
||||
} else {
|
||||
wc_get_template( 'html-wcpay-deactivate-warning.php', [], '', dirname( __DIR__ ) . '/subscriptions/templates/' );
|
||||
}
|
||||
}
|
||||
}
|
||||
+160
@@ -0,0 +1,160 @@
|
||||
<?php
|
||||
/**
|
||||
* Class WC_Payments_Subscriptions.
|
||||
*
|
||||
* @package WooCommerce\Payments
|
||||
*/
|
||||
|
||||
if ( ! defined( 'ABSPATH' ) ) {
|
||||
exit; // Exit if accessed directly.
|
||||
}
|
||||
|
||||
/**
|
||||
* Class for loading WooCommerce Payments Subscriptions.
|
||||
*/
|
||||
class WC_Payments_Subscriptions {
|
||||
|
||||
/**
|
||||
* Instance of WC_Payments_Product_Service, created in init function.
|
||||
*
|
||||
* @var WC_Payments_Product_Service
|
||||
*/
|
||||
private static $product_service;
|
||||
|
||||
/**
|
||||
* Instance of WC_Payments_Order_Service, created in init function.
|
||||
*
|
||||
* @var WC_Payments_Order_Service
|
||||
*/
|
||||
private static $order_service;
|
||||
|
||||
/**
|
||||
* Instance of WC_Payments_Invoice_Service, created in init function.
|
||||
*
|
||||
* @var WC_Payments_Invoice_Service
|
||||
*/
|
||||
private static $invoice_service;
|
||||
|
||||
/**
|
||||
* Instance of WC_Payments_Subscription_Service, created in init function.
|
||||
*
|
||||
* @var WC_Payments_Subscription_Service
|
||||
*/
|
||||
private static $subscription_service;
|
||||
|
||||
/**
|
||||
* Instance of WC_Payments_Subscriptions_Event_Handler, created in init function.
|
||||
*
|
||||
* @var WC_Payments_Subscriptions_Event_Handler
|
||||
*/
|
||||
private static $event_handler;
|
||||
|
||||
/**
|
||||
* Instance of WC_Payments_Subscriptions_Migrator, created in init function.
|
||||
*
|
||||
* @var WC_Payments_Subscriptions_Migrator
|
||||
*/
|
||||
private static $stripe_billing_migrator;
|
||||
|
||||
/**
|
||||
* Initialize WooCommerce Payments subscriptions. (Stripe Billing)
|
||||
*
|
||||
* @param WC_Payments_API_Client $api_client WCPay API client.
|
||||
* @param WC_Payments_Customer_Service $customer_service WCPay Customer Service.
|
||||
* @param WC_Payments_Order_Service $order_service WCPay Order Service.
|
||||
* @param WC_Payments_Account $account WC_Payments_Account.
|
||||
* @param WC_Payments_Token_Service $token_service WC_Payments_Token_Service.
|
||||
*/
|
||||
public static function init( WC_Payments_API_Client $api_client, WC_Payments_Customer_Service $customer_service, WC_Payments_Order_Service $order_service, WC_Payments_Account $account, WC_Payments_Token_Service $token_service ) {
|
||||
// Store dependencies.
|
||||
self::$order_service = $order_service;
|
||||
|
||||
// Load Services.
|
||||
include_once __DIR__ . '/class-wc-payments-product-service.php';
|
||||
include_once __DIR__ . '/class-wc-payments-invoice-service.php';
|
||||
include_once __DIR__ . '/class-wc-payments-subscription-service.php';
|
||||
include_once __DIR__ . '/class-wc-payments-subscription-change-payment-method-handler.php';
|
||||
include_once __DIR__ . '/class-wc-payments-subscriptions-plugin-notice-manager.php';
|
||||
include_once __DIR__ . '/class-wc-payments-subscriptions-empty-state-manager.php';
|
||||
include_once __DIR__ . '/class-wc-payments-subscriptions-event-handler.php';
|
||||
include_once __DIR__ . '/class-wc-payments-subscriptions-onboarding-handler.php';
|
||||
include_once __DIR__ . '/class-wc-payments-subscription-minimum-amount-handler.php';
|
||||
|
||||
// Instantiate additional classes.
|
||||
self::$product_service = new WC_Payments_Product_Service( $api_client );
|
||||
self::$invoice_service = new WC_Payments_Invoice_Service( $api_client, self::$product_service, self::$order_service );
|
||||
self::$subscription_service = new WC_Payments_Subscription_Service( $api_client, $customer_service, self::$product_service, self::$invoice_service );
|
||||
self::$event_handler = new WC_Payments_Subscriptions_Event_Handler( self::$invoice_service, self::$subscription_service );
|
||||
|
||||
new WC_Payments_Subscription_Change_Payment_Method_Handler();
|
||||
new WC_Payments_Subscriptions_Plugin_Notice_Manager();
|
||||
new WC_Payments_Subscriptions_Empty_State_Manager( $account );
|
||||
new WC_Payments_Subscriptions_Onboarding_Handler( $account );
|
||||
new WC_Payments_Subscription_Minimum_Amount_Handler( $api_client );
|
||||
|
||||
if ( class_exists( 'WCS_Background_Repairer' ) ) {
|
||||
include_once __DIR__ . '/class-wc-payments-subscriptions-migrator.php';
|
||||
self::$stripe_billing_migrator = new WC_Payments_Subscriptions_Migrator( $api_client, $token_service );
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the Event Handler class instance.
|
||||
*
|
||||
* @return WC_Payments_Subscriptions_Event_Handler
|
||||
*/
|
||||
public static function get_event_handler() {
|
||||
return self::$event_handler;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the the product service instance.
|
||||
*
|
||||
* @return WC_Payments_Product_Service The product service object.
|
||||
*/
|
||||
public static function get_product_service() {
|
||||
return self::$product_service;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the the invoice service instance.
|
||||
*
|
||||
* @return WC_Payments_Invoice_Service
|
||||
*/
|
||||
public static function get_invoice_service() {
|
||||
return self::$invoice_service;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the the subscription service instance.
|
||||
*
|
||||
* @return WC_Payments_Subscription_Service
|
||||
*/
|
||||
public static function get_subscription_service() {
|
||||
return self::$subscription_service;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the the Stripe Billing migrator instance.
|
||||
*
|
||||
* @return WC_Payments_Subscriptions_Migrator
|
||||
*/
|
||||
public static function get_stripe_billing_migrator() {
|
||||
return self::$stripe_billing_migrator;
|
||||
}
|
||||
|
||||
/**
|
||||
* Determines if this is a duplicate/staging site.
|
||||
*
|
||||
* This function is a wrapper for WCS_Staging::is_duplicate_site().
|
||||
*
|
||||
* @return bool Whether the site is a duplicate URL or not.
|
||||
*/
|
||||
public static function is_duplicate_site() {
|
||||
if ( class_exists( 'WC_Subscriptions' ) && version_compare( WC_Subscriptions::$version, '4.0.0', '<' ) ) {
|
||||
return WC_Subscriptions::is_duplicate_site();
|
||||
}
|
||||
|
||||
return class_exists( 'WCS_Staging' ) && WCS_Staging::is_duplicate_site();
|
||||
}
|
||||
}
|
||||
+73
@@ -0,0 +1,73 @@
|
||||
<?php
|
||||
/**
|
||||
* Admin WC Subscriptions plugin warning template.
|
||||
*
|
||||
* @package WooCommerce\Payments
|
||||
*/
|
||||
|
||||
?>
|
||||
<script type="text/template" id="tmpl-wcpay-subscriptions-plugin-warning">
|
||||
<div id="wcpay-subscriptions-plugin-warning-notice" class="wc-backbone-modal woopayments-plugin-warning-modal">
|
||||
<div class="wc-backbone-modal-content">
|
||||
<section class="wc-backbone-modal-main" role="main">
|
||||
<header class="wc-backbone-modal-header">
|
||||
<h1><?php esc_html_e( 'Are you sure?', 'woocommerce-payments' ); ?></h1>
|
||||
<button class="modal-close modal-close-link dashicons dashicons-no-alt">
|
||||
<span class="screen-reader-text">Close modal panel</span>
|
||||
</button>
|
||||
</header>
|
||||
<article>
|
||||
<p>
|
||||
<?php
|
||||
printf(
|
||||
// Translators: %1-%4 placeholders are opening and closing a or strong HTML tags. %5$s: WooPayments, %6$s: Woo Subscriptions.
|
||||
esc_html__( 'Your store has subscriptions using %5$s Stripe Billing functionality for payment processing. Due to the %1$soff-site billing engine%2$s these subscriptions use,%3$s they will continue to renew even after you deactivate %6$s%4$s.', 'woocommerce-payments' ),
|
||||
'<a href="https://woocommerce.com/document/woopayments/subscriptions/stripe-billing/#faq" target="_blank">',
|
||||
'</a>',
|
||||
'<strong>',
|
||||
'</strong>',
|
||||
'WooPayments',
|
||||
'Woo Subscriptions'
|
||||
);
|
||||
?>
|
||||
</br>
|
||||
</br>
|
||||
<?php
|
||||
printf(
|
||||
// translators: $1 $2 placeholders are opening and closing HTML link tags, linking to documentation. $3 is WooPayments.
|
||||
esc_html__( 'If you do not want these subscriptions to continue to be billed, you should %1$scancel these subscriptions%2$s prior to deactivating %3$s.', 'woocommerce-payments' ),
|
||||
'<a href="https://woocommerce.com/document/subscriptions/store-manager-guide/#cancel-or-suspend-subscription" target="_blank">',
|
||||
'</a>',
|
||||
'Woo Subscriptions'
|
||||
);
|
||||
?>
|
||||
</p>
|
||||
<strong>
|
||||
<?php
|
||||
printf(
|
||||
// translators: Placeholder is "Woo Subscriptions"".
|
||||
esc_html__( 'Are you sure you want to deactivate %s?', 'woocommerce-payments' ),
|
||||
'Woo Subscriptions'
|
||||
);
|
||||
?>
|
||||
</strong>
|
||||
</article>
|
||||
<footer>
|
||||
<div class="inner">
|
||||
<button class="modal-close button button-secondary button-large"><?php esc_html_e( 'Cancel', 'woocommerce-payments' ); ?></button>
|
||||
<button id="wcpay-subscriptions-plugin-deactivation-submit" class="button button-primary button-large">
|
||||
<?php
|
||||
printf(
|
||||
// translators: Placeholder is "Woo Subscriptions"".
|
||||
esc_html__( 'Yes, deactivate %s', 'woocommerce-payments' ),
|
||||
'Woo Subscriptions'
|
||||
);
|
||||
?>
|
||||
</button>
|
||||
</div>
|
||||
</footer>
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
<div class="wc-backbone-modal-backdrop modal-close"></div>
|
||||
</script>
|
||||
+70
@@ -0,0 +1,70 @@
|
||||
<?php
|
||||
/**
|
||||
* Admin WooPayments plugin warning template.
|
||||
*
|
||||
* @package WooCommerce\Payments
|
||||
*/
|
||||
|
||||
?>
|
||||
<script type="text/template" id="tmpl-wcpay-plugin-deactivate-warning">
|
||||
<div id="wcpay-plugin-deactivate-warning-notice" class="wc-backbone-modal woopayments-plugin-warning-modal">
|
||||
<div class="wc-backbone-modal-content">
|
||||
<section class="wc-backbone-modal-main" role="main">
|
||||
<header class="wc-backbone-modal-header">
|
||||
<h1><?php esc_html_e( 'Are you sure?', 'woocommerce-payments' ); ?></h1>
|
||||
<button class="modal-close modal-close-link dashicons dashicons-no-alt">
|
||||
<span class="screen-reader-text">Close modal panel</span>
|
||||
</button>
|
||||
</header>
|
||||
<article>
|
||||
<p>
|
||||
<?php
|
||||
printf(
|
||||
// translators: $1 $2 $3 placeholders are opening and closing HTML link tags, linking to documentation. $4 $5 placeholders are opening and closing strong HTML tags. $6 is WooPayments.
|
||||
esc_html__( 'Your store has active subscriptions using the built-in %6$s functionality. Due to the %1$soff-site billing engine%3$s these subscriptions use, %4$sthey will continue to renew even after you deactivate %6$s%5$s. %2$sLearn more%3$s.', 'woocommerce-payments' ),
|
||||
'<a href="https://woocommerce.com/document/woopayments/subscriptions/comparison/" target="_blank">',
|
||||
'<a href="https://woocommerce.com/document/woopayments/subscriptions/stripe-billing/#deactivate-woopayments-plugin" target="_blank">',
|
||||
'</a>',
|
||||
'<strong>',
|
||||
'</strong>',
|
||||
'WooPayments'
|
||||
);
|
||||
?>
|
||||
<p>
|
||||
</p>
|
||||
<?php
|
||||
printf(
|
||||
// translators: $1 $2 placeholders are opening and closing HTML link tags, linking to documentation. $3 is WooPayments.
|
||||
esc_html__( 'If you do not want these subscriptions to continue to be billed, you should %1$scancel all subscriptions%2$s prior to deactivating %3$s. ', 'woocommerce-payments' ),
|
||||
'<a href="https://woocommerce.com/document/subscriptions/store-manager-guide/#cancel-or-suspend-subscription" target="_blank">',
|
||||
'</a>',
|
||||
'WooPayments'
|
||||
);
|
||||
?>
|
||||
</p>
|
||||
<strong>
|
||||
<?php
|
||||
printf(
|
||||
/* translators: %s: WooPayments. */
|
||||
esc_html__( 'Are you sure you want to deactivate %s?', 'woocommerce-payments' ),
|
||||
'WooPayments'
|
||||
);
|
||||
?>
|
||||
</strong>
|
||||
</article>
|
||||
<footer>
|
||||
<div class="inner">
|
||||
<button class="modal-close button button-secondary button-large"><?php esc_html_e( 'Cancel', 'woocommerce-payments' ); ?></button>
|
||||
<button id="wcpay-plugin-deactivate-modal-submit" class="button button-primary button-large">
|
||||
<?php
|
||||
/* translators: %s: WooPayments */
|
||||
printf( esc_html__( 'Yes, deactivate %s', 'woocommerce-payments' ), 'WooPayments' );
|
||||
?>
|
||||
</button>
|
||||
</div>
|
||||
</footer>
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
<div class="wc-backbone-modal-backdrop modal-close"></div>
|
||||
</script>
|
||||
+72
@@ -0,0 +1,72 @@
|
||||
<?php
|
||||
/**
|
||||
* Admin WC Subscriptions plugin warning template.
|
||||
*
|
||||
* @package WooCommerce\Payments
|
||||
*/
|
||||
|
||||
?>
|
||||
<script type="text/template" id="tmpl-wcpay-plugin-deactivate-warning">
|
||||
<div id="wcpay-plugin-deactivate-warning-notice" class="wc-backbone-modal woopayments-plugin-warning-modal">
|
||||
<div class="wc-backbone-modal-content">
|
||||
<section class="wc-backbone-modal-main" role="main">
|
||||
<header class="wc-backbone-modal-header">
|
||||
<h1><?php esc_html_e( 'Are you sure?', 'woocommerce-payments' ); ?></h1>
|
||||
<button class="modal-close modal-close-link dashicons dashicons-no-alt">
|
||||
<span class="screen-reader-text">Close modal panel</span>
|
||||
</button>
|
||||
</header>
|
||||
<article>
|
||||
<p>
|
||||
<?php
|
||||
printf(
|
||||
// Translators: placeholders are opening and closing strong HTML tags. %6$s: WooPayments, %7$s: Woo Subscriptions.
|
||||
esc_html__( 'Your store has subscriptions using %6$s Stripe Billing functionality for payment processing. Due to the %1$soff-site billing engine%3$s these subscriptions use,%4$s they will continue to renew even after you deactivate %6$s%5$s.', 'woocommerce-payments' ),
|
||||
'<a href="https://woocommerce.com/document/woopayments/subscriptions/stripe-billing/#billing-engines" target="_blank">',
|
||||
'<a href="https://woocommerce.com/document/woopayments/subscriptions/stripe-billing/#deactivate-woopayments-plugin" target="_blank">',
|
||||
'</a>',
|
||||
'<strong>',
|
||||
'</strong>',
|
||||
'WooPayments',
|
||||
);
|
||||
?>
|
||||
</br>
|
||||
</br>
|
||||
<?php
|
||||
printf(
|
||||
// translators: $1 $2 placeholders are opening and closing HTML link tags, linking to documentation. $3 is WooPayments.
|
||||
esc_html__( 'If you do not want these subscriptions to continue to be billed, you should %1$scancel these subscriptions%2$s prior to deactivating %3$s.', 'woocommerce-payments' ),
|
||||
'<a href="https://woocommerce.com/document/subscriptions/store-manager-guide/#cancel-or-suspend-subscription" target="_blank">',
|
||||
'</a>',
|
||||
'WooPayments'
|
||||
);
|
||||
?>
|
||||
</p>
|
||||
<strong>
|
||||
<?php
|
||||
printf(
|
||||
// translators: Placeholder is "Woo Subscriptions"".
|
||||
esc_html__( 'Are you sure you want to deactivate %s?', 'woocommerce-payments' ),
|
||||
'WooPayments'
|
||||
);
|
||||
?>
|
||||
</strong>
|
||||
</article>
|
||||
<footer>
|
||||
<div class="inner">
|
||||
<button class="modal-close button button-secondary button-large"><?php esc_html_e( 'Cancel', 'woocommerce-payments' ); ?></button>
|
||||
<button id="wcpay-plugin-deactivate-modal-submit" class="button button-primary button-large">
|
||||
<?php
|
||||
printf(
|
||||
// translators: Placeholder is "Woo Subscriptions"".
|
||||
esc_html__( 'Yes, deactivate %s', 'woocommerce-payments' ),
|
||||
'WooPayments'
|
||||
);
|
||||
?>
|
||||
</div>
|
||||
</footer>
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
<div class="wc-backbone-modal-backdrop modal-close"></div>
|
||||
</script>
|
||||
Reference in New Issue
Block a user