This commit is contained in:
emmymayo
2025-02-05 23:15:46 +01:00
commit 7269c99357
16995 changed files with 3389680 additions and 0 deletions
@@ -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;
}
}
@@ -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();
} );
@@ -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;
}
}
@@ -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;
}
}
@@ -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;
}
}
@@ -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;
}
}
@@ -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 ) );
}
}
@@ -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>';
}
}
@@ -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' )
)
);
}
}
@@ -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' ) . '&#8230;' : __( '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 );
}
}
@@ -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;
}
}
@@ -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/' );
}
}
}
@@ -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();
}
}
@@ -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>
@@ -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>
@@ -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>