976 lines
33 KiB
PHP
976 lines
33 KiB
PHP
<?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;
|
|
|
|
/**
|
|
* Account service.
|
|
*
|
|
* @var WC_Payments_Account
|
|
*/
|
|
private $account;
|
|
|
|
/**
|
|
* The list of products we need to update at the end of each request.
|
|
*
|
|
* @var array
|
|
*/
|
|
private $products_to_update = [];
|
|
|
|
/**
|
|
* The Stripe account ID.
|
|
*
|
|
* @var string
|
|
*/
|
|
private $stripe_account_id = null;
|
|
|
|
/**
|
|
* Constructor.
|
|
*
|
|
* @param WC_Payments_API_Client $payments_api_client Payments API client.
|
|
* @param WC_Payments_Account $account Account service.
|
|
*/
|
|
public function __construct( WC_Payments_API_Client $payments_api_client, WC_Payments_Account $account ) {
|
|
$this->payments_api_client = $payments_api_client;
|
|
$this->account = $account;
|
|
|
|
/**
|
|
* 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' ] );
|
|
add_action( 'wp_trash_post', [ $this, 'maybe_archive_product' ] );
|
|
|
|
$this->add_product_update_listeners();
|
|
}
|
|
|
|
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 );
|
|
}
|
|
|
|
/**
|
|
* Get or create 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.
|
|
* @throws Exception
|
|
*/
|
|
public function get_or_create_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 ( ! $this->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 ) {
|
|
$this->create_product( $product );
|
|
}
|
|
}
|
|
|
|
return $product->get_meta( self::get_wcpay_product_id_meta_key( $test_mode ), true );
|
|
}
|
|
|
|
/**
|
|
* Get the WCPay product ID for an item type.
|
|
*
|
|
* @param string $type The item type.
|
|
*
|
|
* @return string The WCPay product ID.
|
|
* @throws API_Exception
|
|
*/
|
|
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 );
|
|
$wcpay_product_id = get_option( $option_key_name );
|
|
|
|
// Case 1: No product found, create a new one.
|
|
if ( ! $wcpay_product_id ) {
|
|
return $this->create_product_for_item_type( $sanitized_type );
|
|
}
|
|
|
|
// For existing products, check the linked account.
|
|
$linked_option_key = self::get_wcpay_product_id_linked_to_key( $sanitized_type );
|
|
$linked_account_id = get_option( $linked_option_key );
|
|
$stripe_account_id = $this->account->get_stripe_account_id();
|
|
|
|
// Case 2: Product exists but linked account doesn't, validate and update if needed.
|
|
if ( ! $linked_account_id ) {
|
|
try {
|
|
// Validate that the product exists for the current account.
|
|
$existing_product = $this->payments_api_client->get_product_by_id( $wcpay_product_id );
|
|
|
|
if ( $existing_product ) {
|
|
// Product exists, save with current account ID.
|
|
$this->save_wcpay_product_data( $wcpay_product_id, $stripe_account_id, $sanitized_type );
|
|
return $wcpay_product_id;
|
|
} else {
|
|
// Product doesn't exist, create new one.
|
|
return $this->create_product_for_item_type( $sanitized_type );
|
|
}
|
|
} catch ( \Exception $e ) {
|
|
// Error occurred, create new product.
|
|
Logger::log(
|
|
sprintf(
|
|
'Error occurred when fetching product : wcpay_product_id=%s, account_id=%s, error=%s',
|
|
$wcpay_product_id,
|
|
$stripe_account_id,
|
|
$e->getMessage()
|
|
)
|
|
);
|
|
return $this->create_product_for_item_type( $sanitized_type );
|
|
}
|
|
}
|
|
|
|
// Case 3: Product exists but for a different Stripe account, create new one.
|
|
if ( $linked_account_id !== $stripe_account_id ) {
|
|
return $this->create_product_for_item_type( $sanitized_type );
|
|
}
|
|
|
|
// Case 4: Valid product exists for current account.
|
|
return $wcpay_product_id;
|
|
}
|
|
|
|
/**
|
|
* 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 ) ) );
|
|
}
|
|
|
|
/**
|
|
* Save wcpay product data across two related options.
|
|
*
|
|
* @param string $wcpay_product_id The WooCommerce Payments product ID.
|
|
* @param string $stripe_account_id The Stripe account identifier.
|
|
* @param string $type The item type used to construct the option key.
|
|
*
|
|
* @return void
|
|
*/
|
|
private function save_wcpay_product_data( string $wcpay_product_id, string $stripe_account_id, string $type ) {
|
|
$sanitized_type = self::sanitize_option_key( $type );
|
|
$option_key_name = self::get_wcpay_product_id_option( $sanitized_type );
|
|
|
|
// Store product ID.
|
|
update_option( $option_key_name, $wcpay_product_id );
|
|
|
|
// Store linked stripe account ID.
|
|
$linked_option_key = self::get_wcpay_product_id_linked_to_key( $sanitized_type );
|
|
update_option( $linked_option_key, $stripe_account_id );
|
|
}
|
|
|
|
/**
|
|
* Check if the WC product has a valid WC Pay product ID linked to the current Stripe account.
|
|
*
|
|
* @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 Whether the product has a valid WCPay product ID.
|
|
*/
|
|
public function has_wcpay_product_id( WC_Product $product, $test_mode = null ): bool {
|
|
$option_key = self::get_wcpay_product_id_meta_key( $test_mode );
|
|
$wcpay_product_id = $product->get_meta( $option_key );
|
|
|
|
// No product ID exists.
|
|
if ( empty( $wcpay_product_id ) ) {
|
|
return false;
|
|
}
|
|
|
|
// Check if we have the linked account metadata.
|
|
$linked_option_key = self::get_wcpay_product_id_linked_to_key( null, $test_mode );
|
|
$linked_account_id = $product->get_meta( $linked_option_key );
|
|
$current_account_id = $this->account->get_stripe_account_id();
|
|
|
|
// If we have linked account metadata, just compare with current account.
|
|
if ( ! empty( $linked_account_id ) ) {
|
|
return $linked_account_id === $current_account_id;
|
|
}
|
|
|
|
// Legacy case: we have a product ID but no linked account.
|
|
// Verify if product exists for current account.
|
|
try {
|
|
$product_data = $this->payments_api_client->get_product_by_id( $wcpay_product_id );
|
|
|
|
// Product exists, update metadata with current account.
|
|
if ( ! empty( $product_data ) ) {
|
|
$product->update_meta_data( $linked_option_key, $current_account_id );
|
|
$product->save();
|
|
return true;
|
|
}
|
|
|
|
return false;
|
|
} catch ( \Exception $e ) {
|
|
Logger::log(
|
|
sprintf(
|
|
'Error validating WooPayments product: product_id=%d, wcpay_product_id=%s, account_id=%s, error=%s',
|
|
$product->get_id(),
|
|
$wcpay_product_id,
|
|
$current_account_id,
|
|
$e->getMessage()
|
|
)
|
|
);
|
|
return false;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* 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 ) {
|
|
if ( ! class_exists( 'WC_Subscriptions_Product' ) ) {
|
|
return;
|
|
}
|
|
|
|
// 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 ( ! $this->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 );
|
|
$stripe_account_id = $this->account->get_stripe_account_id();
|
|
|
|
// 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'], $stripe_account_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.
|
|
*
|
|
* @return string The created WCPay product ID.
|
|
* @throws API_Exception
|
|
*/
|
|
private function create_product_for_item_type( string $type ): string {
|
|
$wcpay_product = $this->payments_api_client->create_product(
|
|
[
|
|
'description' => 'N/A',
|
|
'name' => ucfirst( $type ),
|
|
]
|
|
);
|
|
$stripe_account_id = $this->account->get_stripe_account_id();
|
|
$this->save_wcpay_product_data( $wcpay_product['wcpay_product_id'], $stripe_account_id, $type );
|
|
|
|
return $wcpay_product['wcpay_product_id'];
|
|
}
|
|
|
|
/**
|
|
* 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 ( ! class_exists( 'WC_Subscriptions_Product' ) || ! 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 ) {
|
|
if ( ! class_exists( 'WC_Subscriptions_Product' ) ) {
|
|
return;
|
|
}
|
|
|
|
$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 ) {
|
|
if ( ! class_exists( 'WC_Subscriptions_Product' ) ) {
|
|
return;
|
|
}
|
|
|
|
$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() || ! class_exists( 'WC_Subscriptions_Product' ) ) {
|
|
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() || ! class_exists( 'WC_Subscriptions_Product' ) ) {
|
|
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 and the Stripe account it's linked to on a WC product.
|
|
*
|
|
* @param WC_Product $product The product to set the WC Pay ID for.
|
|
* @param string $wcpay_product_id The WC Pay product ID.
|
|
* @param string $stripe_account_id The Stripe account ID.
|
|
*/
|
|
private function set_wcpay_product_id( WC_Product $product, string $wcpay_product_id, string $stripe_account_id ) {
|
|
$option_key = self::get_wcpay_product_id_meta_key();
|
|
$link_key = self::get_wcpay_product_id_linked_to_key();
|
|
$product->update_meta_data( $option_key, $wcpay_product_id );
|
|
$product->update_meta_data( $link_key, $stripe_account_id );
|
|
$product->save();
|
|
}
|
|
|
|
/**
|
|
* Returns the name of the product id option meta, taking test mode into account.
|
|
*
|
|
* @param string|null $type The item type.
|
|
* @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( ?string $type = null, ?bool $test_mode = null ): string {
|
|
$test_mode = null === $test_mode ? WC_Payments::mode()->is_test() : $test_mode;
|
|
$key = $test_mode ? self::TEST_PRODUCT_ID_KEY : self::LIVE_PRODUCT_ID_KEY;
|
|
return $type ? $key . '_' . $type : $key;
|
|
}
|
|
|
|
/**
|
|
* Returns the name of the product id linked to account option meta, taking test mode into account.
|
|
*
|
|
* @param string|null $type The item type.
|
|
* @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_linked_to_key( ?string $type = null, ?bool $test_mode = null ): string {
|
|
$test_mode = null === $test_mode ? WC_Payments::mode()->is_test() : $test_mode;
|
|
$key = $test_mode ? self::TEST_PRODUCT_ID_KEY : self::LIVE_PRODUCT_ID_KEY;
|
|
return ( $type ? $key . '_' . $type : $key ) . '_linked_to';
|
|
}
|
|
|
|
/**
|
|
* Returns the name of the wcpay product id meta key.
|
|
*
|
|
* @param bool|null $test_mode Is WCPay in test, prod or dev mode.
|
|
*
|
|
* @return string The product id meta key.
|
|
* @throws Exception
|
|
*/
|
|
public static function get_wcpay_product_id_meta_key( $test_mode = null ): string {
|
|
// This functions looks the same as the one above.
|
|
// It's here to avoid potential issue when we change the above function.
|
|
$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' => $this->has_wcpay_product_id( $product, false ) ? $this->get_or_create_wcpay_product_id( $product, false ) : null,
|
|
'test' => $this->has_wcpay_product_id( $product, true ) ? $this->get_or_create_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( $price_id_meta_key . '_linked_to' );
|
|
}
|
|
}
|
|
|
|
$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 ( ! class_exists( 'WC_Subscriptions_Product' ) ) {
|
|
return $price_id;
|
|
}
|
|
|
|
// 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 ) {
|
|
$this->create_product( $product );
|
|
$price_id = $product->get_meta( self::get_wcpay_price_id_option(), true );
|
|
}
|
|
}
|
|
|
|
return $price_id;
|
|
}
|
|
}
|