'boost'|'image_boost'|'tts_boost', 'label' => string], …] * contact bool "Leave us a message", sent to CloudScale via the broker's /contact (default true). * refund_days int Money-back window; 0 disables the refund row. * on_activate Closure(array): void Called when status flips to active. * on_deactivate Closure(string): void Called when status flips to cancelled/past_due. * log_fn Closure(string): void Plugin-specific logging function. * action_aliases array ['legacy_action' => 'mapped_suffix'] for back-compat. * paypal_client_id string PayPal REST API client ID (enables PayPal checkout). * paypal_secret string PayPal REST API secret. * paypal_mode string 'sandbox' (default) or 'live'. * paypal_amount string Order amount, default '10.00'. * paypal_billing_text string Billing line shown under PayPal button, default 'Billed $10 USD/month · Cancel anytime'. * paypal_currency string ISO currency code, default 'USD'. * paypal_return_url string URL PayPal redirects to after approval. * paypal_cancel_url string URL PayPal redirects to on cancel. * * Normalized state fields (returned by get_state / passed to set_state): * license_key, status, session_id, email, subscription_code, * usage, limit, boost, reset_date, status_ts, subscribed_at * * DO NOT EDIT THIS FILE in individual plugin directories. * Edit shared-admin-ui/class-cloudscale-licence.php and re-run the shared sync step. */ if ( ! defined( 'ABSPATH' ) ) { exit; } /* * WRAPPED, not guarded by an early return. See class-cloudscale-error-text.php: an * unconditional top-level class can be early-bound by OPcache when the file is * included, before this guard's `return` executes, so the second CloudScale plugin * to load fataled on the very line the guard protects. Invisible under the CLI, * which has no opcache. * * The sync script stamps each plugin's own prefix over this class name, so the name * in this condition is renamed with it and the guard stays consistent in every copy. */ if ( ! class_exists( 'CSDT_Licence' ) ) { class CSDT_Licence { const BROKER = 'https://api.cloudscale.consulting'; /** Nonce action on the one-off ORDER return URL, minted in paypal_create_order(). */ const ORDER_RETURN_NONCE = 'csdt_paypal_order_return'; /** Nonce action on the subscription return URL, minted in render_js_init(). */ const SUB_RETURN_NONCE = 'csdt_paypal_sub_return'; const PAYPAL_SANDBOX = 'https://api-m.sandbox.paypal.com'; const PAYPAL_LIVE = 'https://api-m.paypal.com'; private array $config; private Closure $getter; private Closure $setter; public function __construct( array $config ) { $this->config = $config; $this->getter = Closure::fromCallable( $config['get_state'] ); $this->setter = Closure::fromCallable( $config['set_state'] ); } /** * User-Agent for every request this component makes. * * WordPress's default carries the site's address. The broker needs the site URL and is * given it in the body, where the readme declares it; PayPal needs neither. Component and * product name only, never a URL. This class is shared by five plugins, so it cannot use * any one plugin's version constant. * * @return string */ private function user_agent(): string { return 'CloudScale-Licence/' . (string) ( $this->config['product'] ?? 'plugin' ); } // ── State ───────────────────────────────────────────────────────────────── public function get_state(): array { return ( $this->getter )(); } public function get( string $field, $default = '' ) { return ( $this->getter )()[ $field ] ?? $default; } public function set( array $delta ): void { $state = ( $this->getter )(); ( $this->setter )( array_merge( $state, $delta ) ); } public function clear_session(): void { $this->set( [ 'session_id' => '', 'status' => '' ] ); } private function log( string $msg ): void { if ( isset( $this->config['log_fn'] ) ) { ( $this->config['log_fn'] )( $msg ); } } /** * Customer portal URL. One builder: the broker host used to be a string * literal in render_card() and again in render_js_init(), so a host change * would have moved one and not the other. */ public function manage_url( string $key = '' ): string { return self::BROKER . '/manage' . ( '' !== $key ? ( '?key=' . rawurlencode( $key ) ) : '' ); } private function maybe_cache_subscribers( array $data ): void { if ( isset( $data['subscribers'] ) && is_int( $data['subscribers'] ) ) { set_transient( 'csdt_subscribers_' . $this->config['product'], $data['subscribers'], 12 * HOUR_IN_SECONDS ); } } // ── AJAX registration ───────────────────────────────────────────────────── public function register_ajax(): void { $p = $this->config['action_prefix']; $nonce = $this->config['nonce_name']; $map = [ 'checkout' => [ $this, 'handle_checkout' ], 'poll' => [ $this, 'handle_poll' ], 'save_key' => [ $this, 'handle_save_key' ], 'recover' => [ $this, 'handle_recover' ], 'recover_verify' => [ $this, 'handle_recover_verify' ], 'sync_status' => [ $this, 'handle_sync_status' ], 'disconnect' => [ $this, 'handle_disconnect' ], 'clear_pending' => [ $this, 'handle_clear_pending' ], 'paypal_checkout' => [ $this, 'handle_paypal_checkout' ], 'paypal_subscription' => [ $this, 'handle_paypal_subscription' ], 'boost' => [ $this, 'handle_boost' ], 'contact' => [ $this, 'handle_contact' ], 'refund' => [ $this, 'handle_refund' ], ]; foreach ( $map as $suffix => $cb ) { $action = "{$p}_{$suffix}"; add_action( "wp_ajax_{$action}", function () use ( $cb, $nonce ) { if ( ! current_user_can( 'manage_options' ) ) { wp_send_json_error( 'Forbidden', 403 ); } check_ajax_referer( $nonce, 'nonce' ); // phpcs:ignore WordPress.Security.NonceVerification -- verified here $cb(); } ); } // Legacy action-name aliases (back-compat for existing JS in wild). foreach ( $this->config['action_aliases'] ?? [] as $legacy => $suffix ) { if ( ! isset( $map[ $suffix ] ) ) { continue; } $cb = $map[ $suffix ]; add_action( "wp_ajax_{$legacy}", function () use ( $cb, $nonce ) { if ( ! current_user_can( 'manage_options' ) ) { wp_send_json_error( 'Forbidden', 403 ); } check_ajax_referer( $nonce, 'nonce' ); // phpcs:ignore WordPress.Security.NonceVerification -- verified here $cb(); } ); } // Server-side safety net: complete PayPal subscription activation when // PayPal falls back to a full-page redirect (mobile browsers, popup // blockers) and the JS onApprove callback never runs. add_action( 'admin_init', [ $this, 'maybe_handle_paypal_redirect' ] ); add_action( 'admin_notices', [ $this, 'render_paypal_redirect_notice' ] ); // The one-off ORDER return (boost purchases). Every plugin sets a // paypal_return_url carrying cs_paypal_return, but only Backup ever // registered a listener for it, so a SEO or DevTools customer returning // from a boost payment landed on a page that silently ignored the return // and their credits appeared not to arrive. Registered here so // configuring the return URL is enough to make it work. add_action( 'admin_init', [ $this, 'maybe_handle_paypal_order_return' ] ); } /** * Completes a one-off PayPal ORDER (a boost purchase) when PayPal bounces the * buyer back to paypal_return_url. That URL is built by this site, so it carries a * nonce (ORDER_RETURN_NONCE, minted in paypal_create_order()) which is verified before * the order token is read. The token itself is still untrusted input: it must equal the * order this site created (session_id), handle_paypal_return() captures it against * PayPal's API, and the broker credits server-side before anything is stored. */ public function maybe_handle_paypal_order_return(): void { // Capability first, before any request input is read. A reviewer's scanner // reads top-down, and a $_GET read above the capability check reads as an // unauthenticated read even when nothing is done with the value yet. if ( wp_doing_ajax() || ! current_user_can( 'manage_options' ) ) { return; } if ( empty( $_GET['cs_paypal_return'] ) ) { // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- the nonce for this request is verified on the next line. return; } // Unlike the subscription redirect below, this URL is one this site built and PayPal // echoed back, so it can and does carry a nonce. Anything without a valid one is not // treated as fraud and not captured here: it is left for "Paid but key not found? // Recover licence", which is deliberate and nonce-protected in its own right. if ( ! isset( $_GET['_wpnonce'] ) || ! wp_verify_nonce( sanitize_text_field( wp_unslash( $_GET['_wpnonce'] ) ), self::ORDER_RETURN_NONCE ) ) { $this->log( '[CloudScale Licence] PayPal order return ignored: missing or stale nonce. Use Recover licence.' ); return; } $order_id = sanitize_text_field( wp_unslash( $_GET['token'] ?? '' ) ); if ( '' === $order_id ) { return; } // Still required, and still the stronger half: the returning order must equal the one // this site created and stored as session_id. $this->handle_paypal_return( $order_id ); } /** * Completes a PayPal subscription purchase that returned via full-page * redirect instead of the JS popup flow. PayPal appends subscription_id and * ba_token to the return URL; when both are present and no licence is * active yet, run the same verification and broker activation the * onApprove AJAX path would have run, then redirect to a clean URL. * * The return URL is this site's: render_js_init() mints it with a nonce * (SUB_RETURN_NONCE) and cloudscale-licence.js passes it to PayPal as * application_context.return_url, so a return without a valid nonce is * ignored and left for "Recover licence". The subscription ID is still * untrusted input: it is verified against PayPal's API (status + plan) and * the licence broker re-verifies server-side before any licence is issued. * * NONE OF THAT PROVED THE SUBSCRIPTION WAS OURS. "Exists at PayPal, is on our * plan, is ACTIVE" is true of every other customer's subscription too, and the * subscription ID is right there in the returning URL. So anyone holding a live * subscription on this plan could mail an unlicensed admin * .../wp-admin/?subscription_id=I-THEIRS&ba_token=BA-x, and this site would * activate itself against a subscription it does not own and cannot control: * when they later cancelled, the victim's licence went with it. The capability * check stopped a stranger doing it, not a customer. * * handle_paypal_return() never had this hole because the one-off ORDER flow is * started server-side, so it can require the returning order to equal the stored * session_id. Subscriptions are created client-side by PayPal's JS SDK * (createSubscription in cloudscale-licence.js), the site never sees the ID * before approval, so there is no site-chosen value to compare, and a nonce * proves only that this admin's own browser came back, not which subscription * it carries. The binding therefore has two further halves, both required: * * 1. This site must have OFFERED a subscribe button to THIS admin recently, * recorded server-side by render_card() as a per-user intent transient. * A bare link to an admin who never opened the licence screen does nothing. * 2. The subscription must have been CREATED after that offer. This is the * half that actually kills the attack: an attacker's subscription already * exists, so its PayPal create_time predates the victim's checkout intent. * * Anything that fails the nonce or the binding is not rejected as fraud, it is * left for "Recover licence", which is nonce-protected and deliberate. */ public function maybe_handle_paypal_redirect(): void { // Capability first, before any request input is read; same reasoning as // maybe_handle_paypal_order_return(). if ( wp_doing_ajax() || ! current_user_can( 'manage_options' ) ) { return; } if ( ! isset( $_GET['subscription_id'], $_GET['_wpnonce'] ) || ! wp_verify_nonce( sanitize_text_field( wp_unslash( $_GET['_wpnonce'] ) ), self::SUB_RETURN_NONCE ) ) { return; } $sub_id = sanitize_text_field( wp_unslash( $_GET['subscription_id'] ) ); $ba_token = sanitize_text_field( wp_unslash( $_GET['ba_token'] ?? '' ) ); if ( '' === $sub_id || '' === $ba_token ) { return; // Both params together identify a PayPal approval redirect. } if ( ! preg_match( '/^I-[A-Z0-9]{6,}$/i', $sub_id ) ) { return; } if ( 'active' === $this->get( 'status' ) && $this->get( 'license_key' ) ) { return; // Already licensed; nothing to recover. } if ( ! $this->has_paypal_config() ) { return; } // Half 1 of the binding (see docblock): did this site start a checkout for // this admin? No intent means the subscription ID arrived from somewhere // other than a checkout of ours, so there is nothing here to complete. $intent = (int) get_transient( $this->paypal_intent_key() ); if ( $intent <= 0 ) { $this->log( '[CloudScale Licence] PayPal redirect-return ignored, no checkout intent on this site for ' . $sub_id ); set_transient( $this->redirect_notice_key(), [ 'outcome' => 'error', 'message' => 'A PayPal subscription ID (' . $sub_id . ') was passed to this page, but this site has no record of starting that checkout, ' . 'so it was not activated. If you did just pay, use "Paid but key not found? Recover licence" below and enter the subscription ID there.', ], 5 * MINUTE_IN_SECONDS ); wp_safe_redirect( remove_query_arg( [ 'subscription_id', 'ba_token', 'token', '_wpnonce' ] ) ); exit; } // One-shot: the intent is spent whether activation succeeds or fails, so a // single offer cannot be replayed against a second subscription ID. delete_transient( $this->paypal_intent_key() ); // Half 2 is inside activate_paypal_subscription(): the subscription's PayPal // create_time must not predate the intent. $result = $this->activate_paypal_subscription( $sub_id, $intent ); $this->log( '[CloudScale Licence] PayPal redirect-return for ' . $sub_id . ': ' . $result['outcome'] ); set_transient( $this->redirect_notice_key(), $result, 5 * MINUTE_IN_SECONDS ); wp_safe_redirect( remove_query_arg( [ 'subscription_id', 'ba_token', 'token', '_wpnonce' ] ) ); exit; } /** * Shows the outcome of maybe_handle_paypal_redirect() after its clean-URL * redirect, as a standard admin notice. */ public function render_paypal_redirect_notice(): void { $result = get_transient( $this->redirect_notice_key() ); if ( ! is_array( $result ) || empty( $result['outcome'] ) ) { return; } delete_transient( $this->redirect_notice_key() ); if ( 'active' === $result['outcome'] ) { $class = 'notice-success'; $text = 'PayPal subscription activated. Your licence is connected and ready to use.'; } elseif ( 'pending' === $result['outcome'] ) { $class = 'notice-warning'; $text = (string) $result['message']; } else { $class = 'notice-error'; $text = (string) $result['message']; } printf( '

%s %s

', esc_attr( $class ), esc_html( 'CloudScale licence:' ), esc_html( $text ) ); } private function redirect_notice_key(): string { return 'csdt_lic_pp_redirect_' . $this->config['action_prefix'] . '_' . get_current_user_id(); } /** * Transient holding "this site offered THIS admin a PayPal subscribe button at * this unix time". Per user and per product, because a licence is per product * and the returning browser is the one that was offered the button. */ private function paypal_intent_key(): string { return 'csdt_lic_pp_intent_' . $this->config['action_prefix'] . '_' . get_current_user_id(); } /** * Record that a PayPal subscribe button was rendered for the current admin. * * This is the only place the site learns that a subscription checkout may be * about to happen: PayPal's JS SDK creates the subscription in the browser, so * unlike the ORDER flow there is no server-side "create" call to hang a * session_id off. maybe_handle_paypal_redirect() requires this marker and * requires the returning subscription to be NEWER than it. * * Refreshed rather than rewritten on every admin page load: a blind * set_transient() here would be an option write on every render of the card. * The window is deliberately long enough to cover a real PayPal checkout * (account login, 2FA, funding choice) and no longer. */ private function record_paypal_checkout_intent(): void { if ( ! is_user_logged_in() ) { return; } $existing = (int) get_transient( $this->paypal_intent_key() ); // Re-arm once the marker is over halfway through its life, so an admin who // sat on the page for an hour before clicking still has a valid intent. if ( $existing > 0 && ( time() - $existing ) < ( 30 * MINUTE_IN_SECONDS ) ) { return; } set_transient( $this->paypal_intent_key(), time(), HOUR_IN_SECONDS ); } // ── Broker helpers ──────────────────────────────────────────────────────── /** * $path is a generic parameter, not a literal, so its URL cannot be checked against readme.txt * by grepping this function alone, disclosed in readme.txt: every $path this is actually * called with is documented in each plugin's own External services section. A NEW call site * with a path not already documented there needs a matching readme.txt addition, in every * plugin this file is synced to, in the same commit as the new call site. */ private function broker_get( string $path, array $params = [] ): array { // $path is a parameter, not a literal. The annotation names every value this helper is // called with; the gate checks each one against readme.txt, so a new call site with a new // path fails the build until both this line and the readme are updated. // wporg-disclosed: /status $url = self::BROKER . $path . ( $params ? ( '?' . http_build_query( $params ) ) : '' ); $resp = wp_remote_get( $url, [ 'user-agent' => $this->user_agent(), 'timeout' => 12 ] ); if ( is_wp_error( $resp ) ) { return [ 'ok' => false, 'error' => CSDT_Error_Text::in_seconds( $resp->get_error_message() ) ]; } $data = json_decode( wp_remote_retrieve_body( $resp ), true ); return is_array( $data ) ? $data : [ 'ok' => false, 'error' => 'Invalid broker response.' ]; } /** * Same as broker_get() above: disclosed in readme.txt at endpoint granularity for every path * this is actually called with. A new $path value needs a matching readme.txt addition. */ private function broker_post( string $path, array $body ): array { // As broker_get() above: the annotation names every path this helper is called with, and // each is checked against readme.txt rather than taken on the comment's word. // wporg-disclosed: /checkout /recover /recover/verify /refund-request /contact $resp = wp_remote_post( self::BROKER . $path, [ 'user-agent' => $this->user_agent(), 'timeout' => 20, 'headers' => [ 'Content-Type' => 'application/json' ], 'body' => wp_json_encode( $body ), ] ); if ( is_wp_error( $resp ) ) { return [ 'ok' => false, 'error' => CSDT_Error_Text::in_seconds( $resp->get_error_message() ) ]; } $data = json_decode( wp_remote_retrieve_body( $resp ), true ); return is_array( $data ) ? $data : [ 'ok' => false, 'error' => 'Invalid broker response.' ]; } // ── AJAX handlers ───────────────────────────────────────────────────────── public function handle_checkout(): void { check_ajax_referer( $this->config['nonce_name'], 'nonce' ); $email = sanitize_email( wp_unslash( $_POST['email'] ?? '' ) ); $site_url = esc_url_raw( wp_unslash( $_POST['site_url'] ?? get_site_url() ) ); if ( ! is_email( $email ) ) { wp_send_json_error( [ 'message' => 'Valid email address required.' ] ); } $data = $this->broker_post( '/checkout', [ 'email' => $email, 'site_url' => $site_url, 'type' => 'subscription', 'product' => $this->config['product'], ] ); if ( ! empty( $data['already_subscribed'] ) ) { wp_send_json_success( [ 'already_subscribed' => true, 'manage_url' => $data['manage_url'] ?? '' ] ); } if ( empty( $data['checkout_url'] ) ) { $err = $data['error'] ?? ( $data['message'] ?? 'Checkout failed. Please try again.' ); $this->log( '[CloudScale Licence] Checkout error: ' . $err ); wp_send_json_error( [ 'message' => $err ] ); } $this->set( [ 'session_id' => sanitize_text_field( $data['session_token'] ?? '' ), 'email' => $email, 'status' => 'pending', ] ); wp_send_json_success( [ 'checkout_url' => $data['checkout_url'] ] ); } public function handle_paypal_checkout(): void { check_ajax_referer( $this->config['nonce_name'], 'nonce' ); if ( ! $this->has_paypal_config() ) { wp_send_json_error( [ 'message' => 'PayPal not configured.' ] ); } $email = sanitize_email( wp_unslash( $_POST['email'] ?? '' ) ); $site_url = esc_url_raw( wp_unslash( $_POST['site_url'] ?? get_site_url() ) ); if ( ! is_email( $email ) ) { wp_send_json_error( [ 'message' => 'Valid email address required.' ] ); } $token = $this->paypal_get_access_token(); if ( ! $token ) { wp_send_json_error( [ 'message' => 'PayPal authentication failed. Please try again.' ] ); } $result = $this->paypal_create_order( $token, $email ); if ( ! $result['ok'] ) { $this->log( '[CloudScale Licence] PayPal order creation failed: ' . $result['error'] ); wp_send_json_error( [ 'message' => $result['error'] ] ); } $this->set( [ 'session_id' => 'paypal:' . $result['order_id'], 'email' => $email, 'status' => 'pending', ] ); $this->log( '[CloudScale Licence] PayPal order created: ' . $result['order_id'] ); wp_send_json_success( [ 'checkout_url' => $result['approval_url'] ] ); } public function handle_paypal_subscription(): void { check_ajax_referer( $this->config['nonce_name'], 'nonce' ); $subscription_id = sanitize_text_field( wp_unslash( $_POST['subscription_id'] ?? '' ) ); if ( ! $subscription_id ) { wp_send_json_error( [ 'message' => 'No subscription ID provided.' ] ); } $result = $this->activate_paypal_subscription( $subscription_id ); if ( 'active' === $result['outcome'] ) { wp_send_json_success( [ 'status' => 'active', 'license_key' => $result['license_key'] ] ); } if ( 'pending' === $result['outcome'] ) { wp_send_json_success( [ 'status' => 'pending_broker', 'subscription_id' => $subscription_id, 'message' => $result['message'], ] ); } wp_send_json_error( [ 'message' => $result['message'] ] ); } /** * Verifies a PayPal subscription and activates the licence through the broker. * * Shared core used by the onApprove AJAX path (handle_paypal_subscription), * the server-side return-URL safety net (maybe_handle_paypal_redirect), and * the manual subscription-ID recovery field on the card. * * @param string $subscription_id PayPal subscription ID (I-XXXXXXXXXXXX). * @param int $min_create_time Unix time the subscription must NOT predate, 0 to skip. * Only the untrusted return-URL path passes this; the two * nonce-protected callers (onApprove AJAX, manual recovery) * are deliberate acts by an admin and pass 0, so recovering * a subscription bought last month still works. * @return array{outcome:string,message:string,license_key?:string} * outcome 'active' , licence issued and stored locally; * outcome 'pending', payment approved, broker retrying via handle_poll; * outcome 'error' , hard failure the user must see verbatim. */ private function activate_paypal_subscription( string $subscription_id, int $min_create_time = 0 ): array { if ( ! $this->has_paypal_config() ) { return [ 'outcome' => 'error', 'message' => 'PayPal not configured.' ]; } $token = $this->paypal_get_access_token(); if ( ! $token ) { return [ 'outcome' => 'error', 'message' => 'PayPal authentication failed. Please try again.' ]; } $resp = wp_remote_get( $this->paypal_base_url() . '/v1/billing/subscriptions/' . rawurlencode( $subscription_id ), [ 'user-agent' => $this->user_agent(), 'timeout' => 15, 'headers' => [ 'Authorization' => 'Bearer ' . $token ], ] ); if ( is_wp_error( $resp ) ) { return [ 'outcome' => 'error', 'message' => 'Could not verify subscription with PayPal.' ]; } $data = json_decode( wp_remote_retrieve_body( $resp ), true ); $pp_status = $data['status'] ?? ''; $plan_id = $data['plan_id'] ?? ''; /* * Half 2 of the return-URL binding (see maybe_handle_paypal_redirect()). * * "On our plan and ACTIVE" describes every other customer's subscription as * well as ours, so it is not ownership. What an attacker cannot forge is * time: their subscription already existed when they sent the link, so its * create_time is older than the moment this site offered the victim a * subscribe button. A subscription genuinely created by that offer is newer. * * Fails CLOSED on a missing or unparseable create_time. PayPal returns it on * every subscription, so absence means we are not reading what we think we * are, and the cost of failing closed is one trip through "Recover licence" * rather than a licence bound to someone else's subscription. */ if ( $min_create_time > 0 ) { $created_raw = (string) ( $data['create_time'] ?? '' ); $created_ts = '' !== $created_raw ? strtotime( $created_raw ) : false; // PayPal's clock is not this server's clock; a few minutes either way is // skew, not evidence. Weeks are evidence. if ( false === $created_ts || $created_ts < ( $min_create_time - ( 5 * MINUTE_IN_SECONDS ) ) ) { $this->log( '[CloudScale Licence] PayPal return rejected, subscription ' . $subscription_id . ' predates this site\'s checkout (created: ' . ( $created_raw ?: 'unknown' ) . ').' ); return [ 'outcome' => 'error', 'message' => 'That PayPal subscription (' . $subscription_id . ') was not created by a checkout started on this site, so it was not activated. ' . 'If it is genuinely yours, use "Paid but key not found? Recover licence" below, which verifies ownership by email.', ]; } } if ( ! in_array( $pp_status, [ 'ACTIVE', 'APPROVED', 'APPROVAL_PENDING' ], true ) ) { $this->log( '[CloudScale Licence] PayPal subscription status unexpected: ' . $pp_status ); return [ 'outcome' => 'error', 'message' => 'Subscription not yet active (status: ' . esc_html( $pp_status ) . '). Please try again in a moment.' ]; } $expected_plan = $this->config['paypal_plan_id'] ?? ''; if ( $expected_plan && $plan_id !== $expected_plan ) { $this->log( '[CloudScale Licence] PayPal plan mismatch: ' . $plan_id ); $this->notify_admin( 'PayPal plan mismatch at checkout', "Subscription {$subscription_id} is on plan {$plan_id}, plugin expects {$expected_plan}. Buyer may be billed with no licence, check PayPal.", 'error' ); return [ 'outcome' => 'error', 'message' => "Plan mismatch: your PayPal subscription (ID {$subscription_id}) is on plan {$plan_id}, but this plugin expects plan {$expected_plan}. Do NOT pay again, contact support and quote the subscription ID; it can be attached to your licence or refunded." ]; } $subscriber_email = sanitize_email( $data['subscriber']['email_address'] ?? $this->get( 'email' ) ); // Ask broker to activate, broker must handle payment_method=paypal. $result = $this->broker_post( '/checkout', [ 'email' => $subscriber_email, 'site_url' => get_site_url(), 'type' => 'subscription', 'product' => $this->config['product'], 'payment_method' => 'paypal', 'payment_ref' => $subscription_id, 'subscription_code' => $subscription_id, ] ); if ( ! empty( $result['license_key'] ) ) { $delta = [ 'license_key' => sanitize_text_field( $result['license_key'] ), 'status' => 'active', 'session_id' => '', 'email' => $subscriber_email, 'subscription_code' => $subscription_id, 'subscribed_at' => sanitize_text_field( $result['created_at'] ?? '' ), ]; $this->set( $delta ); if ( isset( $this->config['on_activate'] ) ) { ( $this->config['on_activate'] )( array_merge( $this->get_state(), $delta ) ); } $this->log( '[CloudScale Licence] PayPal subscription activated: ' . $subscription_id ); return [ 'outcome' => 'active', 'message' => '', 'license_key' => $delta['license_key'] ]; } // No licence key back from the broker. NEVER report success here, // the buyer's PayPal subscription exists and may be billing them. $broker_err = (string) ( $result['error'] ?? '' ); $err_code = (string) ( $result['error_code'] ?? '' ); // Transient causes: keep the subscription ID and let handle_poll() // re-attempt activation automatically. Anything else is a hard fail // the user must see verbatim. $retryable = ( '' === $broker_err ) || in_array( $err_code, [ 'paypal_not_active', 'paypal_api_unreachable', 'paypal_api_error' ], true ) || false !== stripos( $broker_err, 'cURL' ) || false !== stripos( $broker_err, 'timed out' ); $this->set( [ 'session_id' => 'paypal_sub:' . $subscription_id, 'status' => $retryable ? 'pending' : '', 'subscription_code' => $subscription_id, 'email' => $subscriber_email, ] ); $this->notify_admin( $retryable ? 'PayPal activation delayed' : 'PayPal checkout FAILED after payment approval', "NEW purchase attempt.\nSubscription: {$subscription_id}\nBuyer: {$subscriber_email}\nSite: " . get_site_url() . "\nLicence server said: " . ( $broker_err ?: 'no response / unreachable' ) . ( $retryable ? "\nPlugin will keep retrying automatically." : "\nBuyer IS BEING BILLED with no licence, fix the cause or cancel the subscription at PayPal." ), $retryable ? 'warning' : 'error' ); $this->log( '[CloudScale Licence] PayPal broker activation ' . ( $retryable ? 'pending retry' : 'failed' ) . ': ' . $subscription_id . ', ' . $broker_err ); if ( $retryable ) { return [ 'outcome' => 'pending', 'message' => 'Your PayPal payment was approved (subscription ' . $subscription_id . '). The licence server is still confirming, this usually takes under a minute and will complete automatically. Do NOT pay again.' . ( $broker_err ? ' (Server said: ' . $broker_err . ')' : '' ), ]; } return [ 'outcome' => 'error', 'message' => 'Your PayPal payment was approved BUT licence activation failed. ' . ( $broker_err ? 'The licence server said: ' . $broker_err . ' ' : '' ) . 'Your PayPal subscription ID is ' . $subscription_id . ', do NOT pay again. Contact support and quote this ID so the payment can be attached to a licence or refunded.', ]; } /** * Send an admin alert through the plugin's configured notify channel * (e.g. Telegram). No-op when the plugin did not configure one. */ private function notify_admin( string $event, string $detail, string $level = 'warning' ): void { if ( isset( $this->config['notify'] ) ) { ( $this->config['notify'] )( $event, $detail, $level ); } } public function handle_poll(): void { $session = $this->get( 'session_id' ); if ( ! $session ) { wp_send_json_error( [ 'message' => 'No pending checkout session.' ] ); } // PayPal session: activation is handled by the return-URL hook in the plugin. if ( 0 === strpos( $session, 'paypal:' ) ) { if ( 'active' === $this->get( 'status' ) && $this->get( 'license_key' ) ) { wp_send_json_success( [ 'status' => 'active', 'license_key' => $this->get( 'license_key' ) ] ); } wp_send_json_success( [ 'status' => 'pending' ] ); } // PayPal subscription awaiting broker activation: re-attempt on every // poll so transient broker failures self-heal without user action. if ( 0 === strpos( $session, 'paypal_sub:' ) ) { $subscription_id = substr( $session, strlen( 'paypal_sub:' ) ); $result = $this->broker_post( '/checkout', [ 'email' => $this->get( 'email' ), 'site_url' => get_site_url(), 'type' => 'subscription', 'product' => $this->config['product'], 'payment_method' => 'paypal', 'payment_ref' => $subscription_id, ] ); if ( ! empty( $result['license_key'] ) ) { $delta = [ 'license_key' => sanitize_text_field( $result['license_key'] ), 'status' => 'active', 'session_id' => '', 'subscription_code' => $subscription_id, 'subscribed_at' => sanitize_text_field( $result['created_at'] ?? '' ), ]; $this->set( $delta ); if ( isset( $this->config['on_activate'] ) ) { ( $this->config['on_activate'] )( array_merge( $this->get_state(), $delta ) ); } $this->log( '[CloudScale Licence] PayPal subscription activated on retry: ' . $subscription_id ); wp_send_json_success( [ 'status' => 'active', 'license_key' => $delta['license_key'] ] ); } wp_send_json_success( [ 'status' => 'pending', 'message' => (string) ( $result['error'] ?? '' ), ] ); } $data = $this->broker_get( '/status', [ 'session_token' => $session, 'product' => $this->config['product'] ] ); $status = $data['status'] ?? 'pending'; $this->maybe_cache_subscribers( $data ); if ( 'active' === $status && ! empty( $data['license_key'] ) ) { // state_from_status() maps every usage field; only the identity of the // newly issued licence is specific to this path. $delta = array_merge( $this->state_from_status( $data ), [ 'license_key' => sanitize_text_field( $data['license_key'] ), 'status' => 'active', 'session_id' => '', ] ); $this->set( $delta ); if ( isset( $this->config['on_activate'] ) ) { ( $this->config['on_activate'] )( $delta ); } $this->log( '[CloudScale Licence] Subscription activated.' ); wp_send_json_success( [ 'status' => 'active', 'license_key' => $delta['license_key'] ] ); } wp_send_json_success( [ 'status' => $status ] ); } public function handle_save_key(): void { check_ajax_referer( $this->config['nonce_name'], 'nonce' ); $key = preg_replace( '/[^a-f0-9]/', '', strtolower( sanitize_text_field( wp_unslash( $_POST['license_key'] ?? '' ) ) ) ); // preg_replace then keeps hex only if ( strlen( $key ) !== 32 ) { wp_send_json_error( [ 'message' => 'Licence key must be 32 hex characters.' ] ); } $data = $this->broker_get( '/status', [ 'key' => $key, 'product' => $this->config['product'] ] ); $status = $data['status'] ?? ''; $this->maybe_cache_subscribers( $data ); if ( 'active' !== $status ) { wp_send_json_error( [ 'message' => $data['error'] ?? "Licence status: {$status}. Please check the key and try again." ] ); } $this->set( array_merge( $this->state_from_status( $data ), [ 'license_key' => $key, 'status' => 'active', 'session_id' => '', ] ) ); $this->log( '[CloudScale Licence] Licence key saved manually.' ); wp_send_json_success( [ 'status' => 'active' ] ); } public function handle_recover(): void { check_ajax_referer( $this->config['nonce_name'], 'nonce' ); $email = sanitize_email( wp_unslash( $_POST['email'] ?? $this->get( 'email' ) ) ); if ( ! is_email( $email ) ) { wp_send_json_error( [ 'message' => 'Valid email address required.' ] ); } $data = $this->broker_post( '/recover', [ 'email' => $email, 'product' => $this->config['product'] ] ); if ( ! empty( $data['error'] ) ) { wp_send_json_error( [ 'message' => $data['error'] ] ); } $this->set( [ 'email' => $email ] ); $this->log( '[CloudScale Licence] Recovery code requested for ' . $email ); wp_send_json_success( [ 'otp_sent' => true ] ); } public function handle_recover_verify(): void { check_ajax_referer( $this->config['nonce_name'], 'nonce' ); $email = sanitize_email( wp_unslash( $_POST['email'] ?? $this->get( 'email' ) ) ); $code = preg_replace( '/[^0-9]/', '', sanitize_text_field( wp_unslash( $_POST['code'] ?? '' ) ) ); // then digits only if ( ! is_email( $email ) || strlen( $code ) !== 6 ) { wp_send_json_error( [ 'message' => 'Enter the 6-digit code from your email.' ] ); } $data = $this->broker_post( '/recover/verify', [ 'email' => $email, 'product' => $this->config['product'], 'code' => $code, ] ); if ( empty( $data['license_key'] ) ) { wp_send_json_error( [ 'message' => $data['error'] ?? 'No active subscription found for that email.' ] ); } $this->set( [ 'license_key' => sanitize_text_field( $data['license_key'] ), 'status' => 'active', 'session_id' => '', 'email' => $email, 'subscription_code' => sanitize_text_field( $data['subscription_code'] ?? '' ), ] ); $this->log( '[CloudScale Licence] Licence recovered via email OTP.' ); wp_send_json_success( [ 'message' => 'Licence reconnected.' ] ); } public function handle_sync_status(): void { // Re-verified here as well as in the register_ajax() wrapper, exactly as // handle_checkout(), handle_save_key(), handle_recover(), handle_boost() and // handle_contact() already do. The wrapper's check_ajax_referer() is the real // gate and is idempotent, so this costs one hash comparison. // // It is here because PHPCS's NonceVerification sniff and WordPress.org's Plugin // Check only recognise a nonce check called in the SAME function scope: they // cannot see the closure in register_ajax(). This handler used to answer that // with a phpcs:ignore reading "nonce verified by register_ajax wrapper" -- true, // but a suppression is a claim, not a check, and it reads identically whether // or not the wrapper still does it. Every sibling handler makes the call // directly; this one was the odd one out. if ( ! current_user_can( 'manage_options' ) ) { wp_send_json_error( 'Forbidden', 403 ); } check_ajax_referer( $this->config['nonce_name'], 'nonce' ); $key = $this->get( 'license_key' ); if ( ! $key ) { wp_send_json_error( [ 'message' => 'No licence key stored.' ] ); } $data = $this->broker_get( '/status', [ 'key' => $key, 'product' => $this->config['product'] ] ); $status = $data['status'] ?? ''; $this->maybe_cache_subscribers( $data ); $prev_status = (string) $this->get( 'status' ); // Licence deleted on the proxy side (refunded/removed). if ( ! empty( $data['error'] ) && false !== stripos( $data['error'], 'not found' ) ) { $this->set( [ 'status' => 'cancelled', 'license_key' => '' ] ); // Alert only on the transition, not on every subsequent sync. // Second arg is human context; existing single-param callbacks ignore it. if ( 'cancelled' !== $prev_status && isset( $this->config['on_deactivate'] ) ) { ( $this->config['on_deactivate'] )( 'cancelled', 'licence no longer exists on the licence server (was: ' . ( $prev_status ?: 'unknown' ) . ')' ); } wp_send_json_success( [ 'status' => 'cancelled' ] ); } if ( '' !== $status ) { $this->set( $this->state_from_status( $data ) ); // Alert only when the status actually CHANGES to a bad state, // re-alerting on every sync while already cancelled is noise that // drowns out real events. if ( in_array( $status, [ 'cancelled', 'past_due' ], true ) && $status !== $prev_status ) { if ( isset( $this->config['on_deactivate'] ) ) { ( $this->config['on_deactivate'] )( $status, 'was: ' . ( $prev_status ?: 'unknown' ) ); } $this->log( '[CloudScale Licence] Status changed: ' . $prev_status . ' → ' . $status ); } } // The card id comes from the DOM, so it is only ever an output id prefix, // never a lookup key. Restricted to id-safe characters all the same. // // sanitize_text_field() sits in the SAME statement as the $_POST read, and // has to: the sniff reports the line the superglobal is touched on, so // cleaning the value after assigning it to a variable still reads as // unsanitised input. It is wrapped by the preg_replace() rather than // replaced by sanitize_key(), which lowercases and would stop the id // matching the element it was read from. Neither call alters the // [A-Za-z0-9_-] that survive, so the value is exactly what it was before. // sanitize_text_field() returns '' for an array, so a crafted card_id[]= // needs no separate guard, and ?? '' keeps this to a single $_POST access on // a single line, phpcs:ignore only covers the line after it, so an isset() // form put a second, unignored read on the line below and traded a // sanitisation error for a nonce one. // // THIS LIVED ONLY IN THE CYBER-DEVTOOLS COPY until 2026-08-14, because that is // the plugin going through WordPress.org review and the fix was made where the // rejection was seen. Syncing this file then overwrote it and reintroduced the // error, caught by that plugin's PHPCS gate on the next build. A fix applied to // a synced copy survives exactly until the next sync, it belongs here. // // No phpcs:ignore for the nonce any more: check_ajax_referer() is called at the // top of this function, in scope, where both PHPCS and Plugin Check can see it. $card_id = preg_replace( '/[^A-Za-z0-9_-]/', '', sanitize_text_field( wp_unslash( $_POST['card_id'] ?? '' ) ) ); wp_send_json_success( [ 'status' => $status, 'cost_mtd_micros' => (int) ( $data['cost_mtd_micros'] ?? 0 ), 'cost_cap_micros' => (int) ( $data['cost_cap_micros'] ?? 0 ), // Every meter, re-resolved from the state we just wrote, so the JS can // refresh each bar in place. Only the spend gauge used to refresh; the // rest sat frozen at their page-load values until a reload. 'meters' => '' !== $card_id ? $this->meter_payload( $card_id ) : [], ] ); } public function handle_disconnect(): void { $state = $this->get_state(); $int_keys = $this->int_state_keys(); foreach ( array_keys( $state ) as $k ) { $state[ $k ] = in_array( $k, $int_keys, true ) ? 0 : ''; } $state['limit'] = $this->default_limit(); ( $this->setter )( $state ); if ( isset( $this->config['on_deactivate'] ) ) { ( $this->config['on_deactivate'] )( 'disconnected' ); } $this->log( '[CloudScale Licence] Licence disconnected.' ); wp_send_json_success(); } public function handle_clear_pending(): void { $this->set( [ 'status' => '', 'session_id' => '' ] ); wp_send_json_success(); } /** * One-off boost purchase (text/image/narration credits). The broker * creates a PayPal order and returns its approve URL; crediting happens * on the broker's /paypal-boost return endpoint. `boost_type` must be one * of the types this plugin configured in config['boosts']. */ public function handle_boost(): void { // Re-verified here as well as in the register_ajax() wrapper. The wrapper does the // real gating (capability, then check_ajax_referer) before it dispatches, and // check_ajax_referer() is idempotent, so this costs one hash comparison. It is here // because WordPress.org's tooling reads handlers one at a time and cannot see a // check that lives in a closure two hundred lines away: this method was reported as // having no nonce check in round 11 (19Aug26) and the answer that time was a // comment, which is not something a reader can verify. A gate you cannot see is // indistinguishable from one that is not there. if ( ! current_user_can( 'manage_options' ) ) { wp_send_json_error( 'Forbidden', 403 ); } check_ajax_referer( $this->config['nonce_name'], 'nonce' ); $allowed = array_column( $this->config['boosts'] ?? [], 'type' ); // phpcs:ignore WordPress.Security.NonceVerification.Missing -- verified directly above $type = sanitize_key( wp_unslash( $_POST['boost_type'] ?? 'boost' ) ); if ( ! in_array( $type, $allowed, true ) ) { wp_send_json_error( [ 'message' => 'Unknown boost type.' ] ); } $key = $this->get( 'license_key' ); if ( ! $key ) { wp_send_json_error( [ 'message' => 'No active subscription found.' ] ); } $email = $this->get( 'email' ) ?: ( wp_get_current_user()->user_email ?? '' ); $result = $this->broker_post( '/checkout', [ 'email' => $email, 'license_key' => $key, 'type' => $type, 'payment_method' => 'paypal', // The broker bounces the buyer back here after capture (host-checked // against the licence's registered site). 'site_url' => $this->config['paypal_cancel_url'] ?? get_site_url(), ] ); if ( empty( $result['checkout_url'] ) ) { wp_send_json_error( [ 'message' => $result['error'] ?? 'Boost checkout could not be started.' ] ); } wp_send_json_success( [ 'checkout_url' => $result['checkout_url'] ] ); } /** * "Leave us a message", sends a support message to CloudScale through the * broker's /contact; the reply goes to the email the user supplies. * * It used to post through CSDT_Telegram, which sends to the bot THIS SITE * configured. On every install but ours that meant the admin messaged their own * Telegram while the form promised "We'll reply by email". The broker delivers it * to CloudScale, and looks up the licence status itself rather than trusting ours. */ public function handle_contact(): void { // Re-verified here as well as in the register_ajax() wrapper. The wrapper does the // real gating (capability, then check_ajax_referer) before it dispatches, and // check_ajax_referer() is idempotent, so this costs one hash comparison. It is here // because WordPress.org's tooling reads handlers one at a time and cannot see a // check that lives in a closure two hundred lines away: this method was reported as // having no nonce check in round 11 (19Aug26) and the answer that time was a // comment, which is not something a reader can verify. A gate you cannot see is // indistinguishable from one that is not there. if ( ! current_user_can( 'manage_options' ) ) { wp_send_json_error( 'Forbidden', 403 ); } check_ajax_referer( $this->config['nonce_name'], 'nonce' ); // phpcs:ignore WordPress.Security.NonceVerification.Missing -- verified directly above $message = trim( sanitize_textarea_field( wp_unslash( $_POST['message'] ?? '' ) ) ); // phpcs:ignore WordPress.Security.NonceVerification.Missing -- verified directly above $email = sanitize_email( wp_unslash( $_POST['email'] ?? '' ) ); if ( '' === $message ) { wp_send_json_error( [ 'message' => 'Please type a message.' ] ); } if ( '' === $email || ! is_email( $email ) ) { wp_send_json_error( [ 'message' => 'A valid email address is required so we know where to reply.' ] ); } // Rate limit: max 10 messages per day (site-wide, resets at UTC midnight). // The broker enforces its own limits too; this one saves the round trip. // // Fully prefixed: WordPress.org requires at least four characters of prefix on every // option and transient, and rejected the three-character one this key used to carry. // Renaming it only resets today's counter. $rl_key = 'csdt_contact_sends_' . gmdate( 'Y-m-d' ); $sent_today = (int) get_transient( $rl_key ); if ( $sent_today >= 10 ) { wp_send_json_error( [ 'message' => 'Daily message limit reached (10 per day). Please email us directly and we will reply.' ] ); } $result = $this->broker_post( '/contact', [ 'email' => $email, 'message' => $message, 'product' => (string) ( $this->config['plugin_name'] ?? 'CloudScale plugin' ), 'site' => (string) ( wp_parse_url( home_url(), PHP_URL_HOST ) ?: '' ), 'key' => (string) $this->get( 'license_key' ), ] ); if ( ! empty( $result['ok'] ) && empty( $result['error'] ) ) { set_transient( $rl_key, $sent_today + 1, DAY_IN_SECONDS ); wp_send_json_success( [ 'message' => "Message sent. We'll reply by email to {$email}." ] ); } wp_send_json_error( [ 'message' => (string) ( $result['error'] ?? 'Could not send your message. Please try again, or email support@cloudscale.consulting.' ) ] ); } /** * Money-back-guarantee refund. The broker cancels the subscription and * returns the payment; local state flips to refunded via the state adapter. */ public function handle_refund(): void { $key = $this->get( 'license_key' ); if ( ! $key ) { wp_send_json_error( [ 'message' => 'No active subscription found.' ] ); } $result = $this->broker_post( '/refund-request', [ 'key' => $key, 'subscription_code' => (string) $this->get( 'subscription_code' ), 'email' => (string) $this->get( 'email' ), ] ); if ( ! empty( $result['ok'] ) ) { $this->set( [ 'status' => 'refunded', 'license_key' => '' ] ); if ( isset( $this->config['on_deactivate'] ) ) { ( $this->config['on_deactivate'] )( 'refunded' ); } wp_send_json_success( [ 'message' => $result['message'] ?? 'Refund processed. Your payment will be returned within 5-10 business days.' ] ); } wp_send_json_error( [ 'message' => $result['error'] ?? ( $result['message'] ?? 'Refund failed. Please try again or contact support.' ) ] ); } /** * Called by the plugin's admin_init hook when PayPal redirects back after approval. * Captures the order and, if the broker supports PayPal activation, stores the licence. */ public function handle_paypal_return( string $order_id ): void { if ( 'paypal:' . $order_id !== $this->get( 'session_id' ) ) { return; // Not our order. } $token = $this->paypal_get_access_token(); if ( ! $token ) { $this->log( '[CloudScale Licence] PayPal return: access token failed.' ); $this->notify_admin( 'PayPal order return: token request failed', "Order {$order_id} returned from PayPal but this site could not authenticate with PayPal's API, so it was not captured. Buyer may be stuck on the return page; check PayPal directly for order {$order_id}.", 'error' ); return; } $base = $this->paypal_base_url(); $resp = wp_remote_post( $base . '/v2/checkout/orders/' . rawurlencode( $order_id ) . '/capture', [ 'user-agent' => $this->user_agent(), 'timeout' => 20, 'headers' => [ 'Authorization' => 'Bearer ' . $token, 'Content-Type' => 'application/json', ], 'body' => '{}', ] ); if ( is_wp_error( $resp ) ) { $this->log( '[CloudScale Licence] PayPal capture failed: ' . CSDT_Error_Text::in_seconds( $resp->get_error_message() ) ); $this->notify_admin( 'PayPal order capture failed', "Order {$order_id} capture request failed: " . CSDT_Error_Text::in_seconds( $resp->get_error_message() ) . '. Buyer may not have been charged; check PayPal directly.', 'error' ); return; } $data = json_decode( wp_remote_retrieve_body( $resp ), true ); if ( 'COMPLETED' !== ( $data['status'] ?? '' ) ) { $this->log( '[CloudScale Licence] PayPal capture status: ' . ( $data['status'] ?? 'unknown' ) ); $this->notify_admin( 'PayPal order not completed', "Order {$order_id} capture returned status " . ( $data['status'] ?? 'unknown' ) . ', not COMPLETED. Check PayPal directly for this order before assuming the buyer was charged.', 'warning' ); return; } $this->log( '[CloudScale Licence] PayPal order captured: ' . $order_id ); // Ask broker to activate, broker must handle payment_method=paypal. $result = $this->broker_post( '/checkout', [ 'email' => $this->get( 'email' ), 'site_url' => get_site_url(), 'type' => 'subscription', 'product' => $this->config['product'], 'payment_method' => 'paypal', 'payment_ref' => $order_id, ] ); if ( ! empty( $result['license_key'] ) ) { $delta = [ 'license_key' => sanitize_text_field( $result['license_key'] ), 'status' => 'active', 'session_id' => '', 'subscribed_at' => sanitize_text_field( $result['created_at'] ?? '' ), ]; $this->set( $delta ); if ( isset( $this->config['on_activate'] ) ) { ( $this->config['on_activate'] )( array_merge( $this->get_state(), $delta ) ); } $this->log( '[CloudScale Licence] PayPal subscription activated.' ); $this->notify_admin( 'PayPal order captured, licence issued', "Order {$order_id} captured and licence key issued on " . get_site_url() . '.', 'info' ); return; } // Broker not yet updated for PayPal: buyer IS charged, session stays pending until polled. $this->notify_admin( 'PayPal order captured, licence NOT yet issued', "Order {$order_id} was captured (buyer charged) but the licence broker did not return a licence key. Session left pending for automatic retry. Broker said: " . ( (string) ( $result['error'] ?? '' ) ?: 'no response' ), 'warning' ); } // ── PayPal helpers ──────────────────────────────────────────────────────── private function has_paypal_config(): bool { return ! empty( $this->config['paypal_client_id'] ) && ! empty( $this->config['paypal_secret'] ); } private function paypal_base_url(): string { return ( 'live' === ( $this->config['paypal_mode'] ?? 'sandbox' ) ) ? self::PAYPAL_LIVE : self::PAYPAL_SANDBOX; } private function paypal_get_access_token(): string { $resp = wp_remote_post( $this->paypal_base_url() . '/v1/oauth2/token', [ 'user-agent' => $this->user_agent(), 'timeout' => 15, 'headers' => [ 'Authorization' => 'Basic ' . base64_encode( ( $this->config['paypal_client_id'] ?? '' ) . ':' . ( $this->config['paypal_secret'] ?? '' ) ), // phpcs:ignore WordPress.PHP.DiscouragedPHPFunctions.obfuscation_base64_encode -- PayPal Basic Auth requires base64-encoded credentials per OAuth spec 'Content-Type' => 'application/x-www-form-urlencoded', ], 'body' => 'grant_type=client_credentials', ] ); if ( is_wp_error( $resp ) ) { $this->log( '[CloudScale Licence] PayPal token request failed: ' . CSDT_Error_Text::in_seconds( $resp->get_error_message() ) ); return ''; } $data = json_decode( wp_remote_retrieve_body( $resp ), true ); return $data['access_token'] ?? ''; } private function paypal_create_order( string $token, string $email ): array { $base = $this->paypal_base_url(); $amount = $this->config['paypal_amount'] ?? '10.00'; $currency = $this->config['paypal_currency'] ?? 'USD'; // The return URL is OURS: we hand it to PayPal and PayPal gives it back with its own // parameters appended, so a nonce minted here survives the round trip. It is minted at // this point, inside an admin AJAX request, and never in the plugin's config array, // which is built before `init` when wp_create_nonce() has no user session to bind to. // add_query_arg(), not wp_nonce_url(): that helper HTML-escapes its result, so the "&" // between parameters became "&" inside a JSON body PayPal stores verbatim. On the // way back PHP then saw "amp;cs_paypal_return" and "amp;_wpnonce", the handler found // neither, returned silently, and the order was never captured. $return_url = add_query_arg( '_wpnonce', wp_create_nonce( self::ORDER_RETURN_NONCE ), $this->config['paypal_return_url'] ?? admin_url( 'tools.php?page=cloudscale-backup&cs_paypal_return=1' ) ); $cancel_url = $this->config['paypal_cancel_url'] ?? admin_url(); $resp = wp_remote_post( $base . '/v2/checkout/orders', [ 'user-agent' => $this->user_agent(), 'timeout' => 20, 'headers' => [ 'Authorization' => 'Bearer ' . $token, 'Content-Type' => 'application/json', ], 'body' => wp_json_encode( [ 'intent' => 'CAPTURE', 'purchase_units' => [ [ 'amount' => [ 'currency_code' => $currency, 'value' => $amount ], 'description' => sprintf( 'CloudScale %s, Monthly', $this->config['plugin_name'] ?? 'Plugin' ), 'custom_id' => sanitize_key( $email ) . ':' . $this->config['product'], ], ], 'application_context' => [ 'brand_name' => 'CloudScale', 'return_url' => $return_url, 'cancel_url' => $cancel_url, 'user_action' => 'PAY_NOW', ], ] ), ] ); if ( is_wp_error( $resp ) ) { return [ 'ok' => false, 'error' => CSDT_Error_Text::in_seconds( $resp->get_error_message() ) ]; } $code = (int) wp_remote_retrieve_response_code( $resp ); $data = json_decode( wp_remote_retrieve_body( $resp ), true ); if ( ! is_array( $data ) ) { return [ 'ok' => false, 'error' => "PayPal returned HTTP {$code} but the response was not valid JSON." ]; } if ( $code < 200 || $code >= 300 || empty( $data['id'] ) ) { $err = $data['message'] ?? ( $data['error_description'] ?? "PayPal order creation failed (HTTP {$code})." ); return [ 'ok' => false, 'error' => $err ]; } $approval_url = ''; foreach ( $data['links'] ?? [] as $link ) { if ( 'approve' === ( $link['rel'] ?? '' ) ) { $approval_url = $link['href']; break; } } if ( ! $approval_url ) { return [ 'ok' => false, 'error' => 'PayPal did not return an approval URL.' ]; } return [ 'ok' => true, 'order_id' => $data['id'], 'approval_url' => $approval_url ]; } // ── Card rendering ──────────────────────────────────────────────────────── /** * Render the full licence management card. * * @param array $display { * card_id string Id prefix for every element and JS hook. Pass the SAME value to * render_js_init(), or none of the buttons will be wired up. * variant string 'full' (default) draws the card's own frame and heading; * 'compact' drops both, for embedding inside a surface that * already has them (the SEO onboarding pricing card). * icon string Emoji icon shown in card header. * heading string Card heading text. Empty (the compact default) omits it. * price_text string Short pricing description for subscribe state. Empty omits it. * badge_right string Right-aligned text inside the status chip, e.g. credits_left_text(). * cta_text string Primary subscribe button text. * explain_fn string JS function name called by the CTA button. * card_bg string CSS background color for the card. * card_border string CSS border color for the card. * active_extra callable|null Called inside active state to inject plugin-specific HTML. * Use config['meters'] for NUMBERS; this slot is for links and * controls. Do not use it to smuggle a metric in, and never * overwrite a meter's -stats span from plugin JS: declare a * meter with a value_fn instead. * subscribe_intro_html string Optional HTML (wp_kses_post) shown at the top of the * not-subscribed state, marketing bullets, recovery banners, etc. * open_recover boolean Render the recover form already open (e.g. after a * detected paid-but-disconnected state). * * Active-state features come from the constructor config, shared by all plugins: * config['boosts'] array [['type' => 'boost'|'image_boost'|'tts_boost', 'label' => string], …] * config['contact'] bool "Leave us a message", sent to CloudScale via the broker's /contact (default true). * config['refund_days'] int Money-back window; 0 disables the refund row. * config['usage_label'] string Noun for the usage bar (default 'requests'). * text_domain string Deprecated, ignored. Card strings are intentionally untranslated: * a shared component cannot use a per-plugin literal text domain, * and a variable domain is a Plugin Check Critical * (WordPress.WP.I18n.NonSingularStringLiteralDomain). * } */ /** * Extra managed-usage fields captured from a /status payload, so the licence * card can show every allowance (audio narration credits, images), not only * text requests. Each falls back to the currently-stored value when the proxy * omits it, so an older broker never zeroes a meter it does not report. * * @param array $data Decoded /status response. * @return array */ /** * Map a decoded /status payload onto normalized licence state. * * The one mapper. Plugins that refresh status on their own schedule (the SEO * plugin has a throttled background refresh and an AJAX one) must call this * rather than picking fields out of $data by hand: both of its hand-rolled * copies silently dropped cost_mtd / cost_cap and the image fields, so a * licence could sync repeatedly and still show no spend gauge. * * Every field falls back to the currently-stored value, so an older broker * that omits a key never zeroes it. * * @param array $data Decoded /status response. * @return array Delta suitable for set(). */ public function state_from_status( array $data ): array { $delta = [ 'usage' => (int) ( $data['monthly_requests'] ?? $this->get( 'usage', 0 ) ), 'limit' => (int) ( $data['monthly_limit'] ?? $this->get( 'limit', $this->default_limit() ) ), 'boost' => (int) ( $data['boost_balance'] ?? $this->get( 'boost', 0 ) ), 'cost_mtd' => (int) ( $data['cost_mtd_micros'] ?? $this->get( 'cost_mtd', 0 ) ), 'cost_cap' => (int) ( $data['cost_cap_micros'] ?? $this->get( 'cost_cap', 0 ) ), 'reset_date' => sanitize_text_field( (string) ( $data['reset_date'] ?? $this->get( 'reset_date' ) ) ), 'status_ts' => time(), ]; if ( ! empty( $data['status'] ) ) { $delta['status'] = sanitize_text_field( (string) $data['status'] ); } if ( ! empty( $data['subscription_code'] ) ) { $delta['subscription_code'] = sanitize_text_field( (string) $data['subscription_code'] ); } if ( ! empty( $data['created_at'] ) ) { $delta['subscribed_at'] = sanitize_text_field( (string) $data['created_at'] ); } return array_merge( $delta, $this->extra_usage_fields( $data ) ); } private function extra_usage_fields( array $data ): array { return [ 'tts_usage' => (int) ( $data['monthly_tts'] ?? $this->get( 'tts_usage', 0 ) ), 'tts_limit' => (int) ( $data['monthly_tts_limit'] ?? $this->get( 'tts_limit', 0 ) ), 'tts_boost' => (int) ( $data['tts_boost_balance'] ?? $this->get( 'tts_boost', 0 ) ), // Humanize characters (SEO). Same three names as narration and the same // fallback, so an older broker that does not send them never zeroes a meter. 'humanize_usage' => (int) ( $data['monthly_humanize'] ?? $this->get( 'humanize_usage', 0 ) ), 'humanize_limit' => (int) ( $data['monthly_humanize_limit'] ?? $this->get( 'humanize_limit', 0 ) ), 'humanize_boost' => (int) ( $data['humanize_boost_balance'] ?? $this->get( 'humanize_boost', 0 ) ), 'img_usage' => (int) ( $data['monthly_images'] ?? $this->get( 'img_usage', 0 ) ), 'img_limit' => (int) ( $data['monthly_image_limit'] ?? $this->get( 'img_limit', 0 ) ), 'img_boost' => (int) ( $data['image_boost_balance'] ?? $this->get( 'img_boost', 0 ) ), // Managed-backup storage, measured server-side from S3. Zero for every // other product. Summing these client-side from /backup/list silently // under-reported past 100 backups, because that endpoint caps its page. 'backup_count' => (int) ( $data['backup_count'] ?? $this->get( 'backup_count', 0 ) ), 'backup_bytes' => (int) ( $data['backup_bytes_stored'] ?? $this->get( 'backup_bytes', 0 ) ), 'backup_limit' => (int) ( $data['backup_max_total_bytes'] ?? $this->get( 'backup_limit', 0 ) ), 'backup_last_ts' => (int) ( $data['backup_last_ts'] ?? $this->get( 'backup_last_ts', 0 ) ), // Live model id (e.g. z-ai/glm-5.2) so the panel can show which model is // running, refreshed on this async call, never a blocking on-render fetch. 'ai_model' => sanitize_text_field( (string) ( $data['ai_model'] ?? $this->get( 'ai_model', '' ) ) ), ]; } // ── Presentation helpers ────────────────────────────────────────────────── // // Every licence surface in every plugin renders status through these. Before // they existed, the same status-to-label-and-colour ladder was written out six // times (this card, both of Backup's bespoke blocks, the DevTools home tile, // the SEO onboarding pane and its inline JS) and had drifted apart: different // labels, different palettes, "Manage Subscription" against "Manage // subscription", "Sep 1" against "01 Sep 2026". If you need a licence badge, // a reset date or a credits-left string anywhere, call these. Do not write // another ladder; check-licence-ui-shared.php fails the build if you do. /** * The canonical badge for each licence state. One table, so a label or a * colour can only be changed in one place. * * The icons are literal UTF-8 characters, not HTML entities, so that every output path can * run them through esc_html() like any other string. As entities they could only be echoed * raw, esc_html('✓') renders the visible text "✓", not a tick, and an * unescaped echo is a WordPress.org rejection regardless of the value being a plugin * constant. Literal characters make the safe call the correct one. */ const BADGE_MAP = [ 'active' => [ 'label' => 'License: Active', 'icon' => '✓', 'bg' => '#dcfce7', 'fg' => '#15803d' ], 'past_due' => [ 'label' => 'License: Payment Failed', 'icon' => '⚠', 'bg' => '#fef9c3', 'fg' => '#854d0e' ], 'cancelled' => [ 'label' => 'License: Cancelled', 'icon' => '✕', 'bg' => '#fee2e2', 'fg' => '#dc2626' ], 'pending' => [ 'label' => 'License: Activating', 'icon' => '…', 'bg' => '#dbeafe', 'fg' => '#1d4ed8' ], 'none' => [ 'label' => 'License: Not set up', 'icon' => '', 'bg' => '#f3f4f6', 'fg' => '#4b5563' ], 'invalid' => [ 'label' => 'License: Not Found', 'icon' => '✕', 'bg' => '#fee2e2', 'fg' => '#dc2626' ], ]; /** * Reduce a raw broker status to the canonical state this UI recognises. * * Surfaces that legitimately style themselves differently (the DevTools home * tile has its own palette and its own per-state call to action) should switch * on THIS rather than on the raw status, so they cannot end up disagreeing * with the card about which state a licence is in. * * @param string $status Licence status as reported by the broker. * @param bool $has_key A stored licence key. 'active' without one is not usable. * @param bool $has_session An open checkout session. 'pending' without one is stale. * @return string active|past_due|cancelled|pending|none|invalid */ public static function status_key( string $status, bool $has_key = true, bool $has_session = true ): string { if ( 'active' === $status && $has_key ) { return 'active'; } if ( 'past_due' === $status ) { return 'past_due'; } if ( 'cancelled' === $status ) { return 'cancelled'; } if ( 'pending' === $status && $has_session ) { return 'pending'; } if ( '' === $status ) { return 'none'; } // A status we do not recognise, or 'active' with no key to use: a licence we // cannot act on, rather than silently showing green. return 'invalid'; } /** status_key() for a normalized state array. */ public static function state_key( array $state ): string { return self::status_key( (string) ( $state['status'] ?? '' ), '' !== (string) ( $state['license_key'] ?? '' ), '' !== (string) ( $state['session_id'] ?? '' ) ); } /** * The single status-to-badge mapping. * * @return array{label:string,icon:string,bg:string,fg:string} */ public static function status_badge( string $status, bool $has_key = true, bool $has_session = true ): array { return self::BADGE_MAP[ self::status_key( $status, $has_key, $has_session ) ]; } /** status_badge() for a normalized state array. */ public static function state_badge( array $state ): array { return self::BADGE_MAP[ self::state_key( $state ) ]; } /** * Render the status chip. * * A licence that was never set up shows no chip by default, so the subscribe * state stays clean; pass show_unset for surfaces that want an explicit * "Not set up" (Backup's managed-history empty state does). * * @param array $state Normalized licence state. * @param array $args show_unset bool, right_text string, id string, style string. */ public static function render_badge( array $state, array $args = [] ): void { if ( 'none' === self::state_key( $state ) && empty( $args['show_unset'] ) ) { return; } $badge = self::state_badge( $state ); $label = trim( $badge['icon'] . ' ' . esc_html( $badge['label'] ) ); ?> id="" style="display:flex;align-items:center;justify-content:space-between;gap:8px;margin-bottom:8px;padding:6px 10px;background:;border-radius:6px;font-size:0.8rem;font-weight:700;color:;"> 0 || $limit <= 0 ) { return $left; } return number_format( self::credits_left( $state ) ) . ' of ' . number_format( $limit ) . ' credits left'; } // ── Usage meters ────────────────────────────────────────────────────────── // // One renderer for every bar on the card. The primary allowance, the extra // allowances from config['meters'] and the spend gauge used to be three // near-identical open-coded blocks with three colour ladders and two number // formats between them. Worse, a zero or unknown limit drew an empty grey // track captioned "N / 0 used (0%)", which is exactly what made the Backup // card look broken, and what tempted Backup into overwriting the card's // stats span from its own JS instead of declaring a meter. // // A meter takes its values either from licence-state keys (usage_key / // limit_key / boost_key, for allowances the broker reports) or from a // value_fn (for metrics the plugin computes itself, like backup count and // bytes stored). Both go through resolve_meter() and come out identical. const METER_COLORS = [ '#22c55e', '#f59e0b', '#ef4444' ]; const METER_COLORS_COST = [ '#22c55e', '#b45309', '#b91c1c' ]; /** * Resolve a meter descriptor into a render-ready meter, or null when it * should not be shown at all. * * Descriptor keys, all optional except a source of values: * label string Uppercase heading above the bar. * label_inline bool Prefix the label into the caption instead ("Spend this month: …"). * slug string Id-safe suffix; ids become {card_id}-{slug}-stats etc. * id_base string Override the id prefix (the primary meter keeps {card_id}-stats). * unit string Plural noun for the amounts ("images", "chars"). * usage_key string State key for the amount used. * limit_key string State key for the monthly allowance. * boost_key string State key for the rolled-over boost balance. * value_fn callable fn(array $state): ['used'=>int,'limit'=>int,'boost'=>int]. * format string int (default) | chars | gb | bytes | money | datetime. * gb pins the unit to GB (use it for anything sold in * GB, so used and limit are directly comparable); * bytes picks the largest fitting unit. * hide_bar bool Show the caption only, for a count with no ceiling. * thresholds array [amber, red] percentages; default [70, 90]. * colors array [ok, warn, over]; default METER_COLORS. * always bool Render even when there is nothing to report. * suffix_text string Right-aligned text on the caption row (the reset date). * over_note string Warning shown under the bar once at 100%. * * @return array|null */ private function resolve_meter( array $meter, array $state, string $card_id ): ?array { $slug = (string) ( $meter['slug'] ?? 'meter' ); if ( isset( $meter['value_fn'] ) && is_callable( $meter['value_fn'] ) ) { $vals = (array) ( $meter['value_fn'] )( $state ); $used = (int) ( $vals['used'] ?? 0 ); $limit = (int) ( $vals['limit'] ?? 0 ); $boost = (int) ( $vals['boost'] ?? 0 ); // The plugin computed this deliberately, so trust it and show it. $have = true; } else { $used = (int) ( $state[ $meter['usage_key'] ?? '' ] ?? 0 ); $limit = (int) ( $state[ $meter['limit_key'] ?? '' ] ?? 0 ); $boost = (int) ( $state[ $meter['boost_key'] ?? '' ] ?? 0 ); // Nothing allowed and nothing spent means this plugin does not have // the feature on this licence; hide it rather than showing a zero. $have = ( $limit > 0 || $boost > 0 || $used > 0 ); } if ( ! $have && empty( $meter['always'] ) ) { return null; } // THE CEILING INCLUDES THE BOOST. A boost balance rolls over and never // expires, so it adds to this month's quota, credits_left() has always // said so, and this meter disagreed with it. // // Reported as "I increase these with a boost but it's showing old // values". With 30 of a 30-image quota spent and 21 boost images bought, // the card read "30 / 30 images used (100%)" with a full red bar, and // mentioned the 21 only in a separate green line underneath. So the one // number a reader checks before deciding whether they can generate an // image said they were out, while the line below it said they had 21, // and they could in fact generate 21 more. The boost had been paid for // and the panel was still reporting the pre-boost ceiling. // // This also subsumes the boost-only case (quota spent or never set, but // rolled-over credits remain), which used to be special-cased into // $limit because dividing by a zero limit rendered "N / 0 chars used // (0%)". Nothing needs to move now: 0 + boost is the ceiling like any // other, and the boost keeps its own line rather than being zeroed to // hide the double count that no longer exists. $ceiling = $limit + $boost; // True percentage in the caption so going over quota reads honestly; the // bar width is clamped separately so it cannot overflow its track. $pct = $ceiling > 0 ? (int) round( $used / $ceiling * 100 ) : 0; $thresh = array_values( (array) ( $meter['thresholds'] ?? [ 70, 90 ] ) ); $colors = array_values( (array) ( $meter['colors'] ?? self::METER_COLORS ) ); $resolved = [ 'slug' => $slug, 'id_base' => (string) ( $meter['id_base'] ?? ( $card_id . '-' . $slug ) ), 'label' => (string) ( $meter['label'] ?? '' ), 'label_inline' => ! empty( $meter['label_inline'] ), 'unit' => (string) ( $meter['unit'] ?? '' ), 'used' => $used, // The ceiling, because every reader of 'limit', the caption, the bar, // the over-quota note, wants what can actually be spent. The base // quota is kept alongside it for anything that needs to say where the // ceiling came from. 'limit' => $ceiling, 'base_limit' => $limit, 'boost' => $boost, 'format' => (string) ( $meter['format'] ?? 'int' ), 'pct' => $pct, 'bar_pct' => max( 0, min( 100, $pct ) ), 'color' => $pct >= (int) $thresh[1] ? $colors[2] : ( $pct >= (int) $thresh[0] ? $colors[1] : $colors[0] ), // No ceiling means there is nothing to draw a bar against. Drawing an // empty grey track instead is what made the Backup card look broken. // Against the CEILING, not the base quota: a boost-only allowance has // something to spend and therefore something to draw. 'show_bar' => empty( $meter['hide_bar'] ) && $ceiling > 0, 'suffix_text' => (string) ( $meter['suffix_text'] ?? '' ), 'over_note' => (string) ( $meter['over_note'] ?? '' ), ]; $resolved['text'] = self::meter_text( $resolved ); return $resolved; } /** Format one amount according to a meter's format. */ private static function format_amount( int $value, string $format ): string { // Always GB, whatever the magnitude. Storage plans are sold in GB, so a // meter reading "512 MB / 25 GB" makes the reader convert before they can // judge how full they are, and a total in MB is unreadable at scale: this // card shipped "81269 MB" where it meant 79 GB. if ( 'gb' === $format ) { return number_format( $value / GB_IN_BYTES, 1 ) . ' GB'; } // Largest fitting unit, for sizes that are genuinely of mixed magnitude // (a single backup file is better read as "212 MB" than "0.2 GB"). if ( 'bytes' === $format ) { return $value > 0 ? size_format( $value, $value < GB_IN_BYTES ? 0 : 1 ) : '0 B'; } if ( 'money' === $format ) { return '$' . number_format( $value / 1000000, 2 ); } if ( 'datetime' === $format ) { return $value > 0 ? gmdate( 'd M Y H:i', $value ) : 'never'; } return number_format( $value ); } /** Caption above a meter's bar, e.g. "105 / 200 AI requests used (53%)". */ private static function meter_text( array $m ): string { $prefix = ( $m['label_inline'] && '' !== $m['label'] ) ? $m['label'] . ': ' : ''; $used = self::format_amount( $m['used'], $m['format'] ); if ( 'datetime' === $m['format'] ) { return $prefix . $used; } // size_format() and the money format already carry their own unit. $unit = ( '' !== $m['unit'] && ! in_array( $m['format'], [ 'money', 'bytes' ], true ) ) ? ' ' . $m['unit'] : ''; if ( $m['limit'] <= 0 ) { // A count with no ceiling: "61 backups", never "61 / 0 backups (0%)". return $prefix . $used . $unit; } $used_word = 'money' === $m['format'] ? '' : ' used'; return $prefix . $used . ' / ' . self::format_amount( $m['limit'], $m['format'] ) . $unit . $used_word . ' (' . $m['pct'] . '%)'; } /** * Render one resolved meter. Element ids are stable and predictable: * {id_base}-stats for the caption, -bar for the fill, -note for the warning, * -meter for the wrapper. The primary meter's id_base is the bare card id, so * it keeps {card_id}-stats, and the spend gauge keeps {card_id}-cost-stats / * -cost-bar / -cost-note, which is what the shared JS and the UI tests target. */ private function render_meter( array $m ): void { $base = $m['id_base']; ?>
0 ) : ?>
⚡
⚠
Resolved meters, ready for render_meter(). */ private function build_meters( array $state, string $card_id ): array { $reset = self::reset_date_text( $state['reset_date'] ?? '' ); $meters = []; // Primary allowance: the headline number. Always shown while active, even // with no known limit, in which case it reports the count alone. $primary = $this->resolve_meter( [ 'slug' => 'usage', 'id_base' => $card_id, 'unit' => (string) ( $this->config['usage_label'] ?? 'requests' ), 'always' => true, 'suffix_text' => '' !== $reset ? 'Resets ' . $reset : '', 'value_fn' => static function ( array $s ): array { return [ 'used' => (int) ( $s['usage'] ?? 0 ), 'limit' => (int) ( $s['limit'] ?? 0 ), 'boost' => (int) ( $s['boost'] ?? 0 ), ]; }, ], $state, $card_id ); if ( $primary ) { $meters[] = $primary; } foreach ( (array) ( $this->config['meters'] ?? [] ) as $meter ) { $resolved = $this->resolve_meter( (array) $meter, $state, $card_id ); if ( $resolved ) { $meters[] = $resolved; } } // Spend gauge: month-to-date cost against the licence cost cap, from the // same /status payload, so it costs no extra request. Hidden when the // broker reports no cap. $cost = $this->resolve_meter( [ 'slug' => 'cost', 'label' => 'Spend this month', 'label_inline' => true, 'format' => 'money', 'thresholds' => [ 80, 100 ], 'colors' => self::METER_COLORS_COST, 'over_note' => 'Monthly cost cap reached. Managed AI is paused until it resets.', 'value_fn' => static function ( array $s ): array { return [ 'used' => (int) ( $s['cost_mtd'] ?? 0 ), 'limit' => (int) ( $s['cost_cap'] ?? 0 ), ]; }, ], $state, $card_id ); if ( $cost && $cost['limit'] > 0 ) { $meters[] = $cost; } return $meters; } /** * Meter values for a live refresh, keyed by the element id prefix so the JS * can update captions and bars in place without knowing what each plugin * meters. Previously only the spend gauge refreshed and every other bar sat * frozen at its page-load value until reload. * * @return array> */ public function meter_payload( string $card_id ): array { $out = []; foreach ( $this->build_meters( $this->get_state(), $card_id ) as $m ) { $out[] = [ 'idBase' => $m['id_base'], // So a second card for the same licence on the same page can derive // its own ids: the primary meter is the bare card id, every other // meter is {card_id}-{slug}. Matches build_meters(). 'slug' => $m['slug'], 'text' => $m['text'], 'barPct' => $m['bar_pct'], 'color' => $m['color'], 'showBar' => $m['show_bar'], 'over' => $m['pct'] >= 100, ]; } return $out; } /** Primary allowance default, used when the broker omits monthly_limit. */ private function default_limit(): int { return (int) ( $this->config['default_limit'] ?? 200 ); } /** * Licence-state keys that hold integers, so disconnect can zero them rather * than blanking them. Derived from the configured meters so a new meter * cannot be forgotten here, which used to leave its key set to '' instead * of 0 and then fail an (int) read somewhere far away. * * @return string[] */ private function int_state_keys(): array { $keys = [ 'usage', 'limit', 'boost', 'cost_mtd', 'cost_cap', 'status_ts' ]; foreach ( (array) ( $this->config['meters'] ?? [] ) as $meter ) { foreach ( [ 'usage_key', 'limit_key', 'boost_key' ] as $k ) { if ( ! empty( $meter[ $k ] ) ) { $keys[] = (string) $meter[ $k ]; } } } return array_values( array_unique( $keys ) ); } /** * The "Leave us a message" form, sent to CloudScale through the broker's /contact * (see handle_contact()). Rendered in both card states: subscribers get "Leave us a * message", and a site that has not subscribed yet gets "Ask us a question" beside the * PayPal button, so a pre-sale question reaches us instead of stalling the checkout. * The card renders one state or the other, never both, so the element ids stay unique. * * @param string $card_id Card id prefix for element ids. * @param string $email Stored licence email, or '' to fall back to the admin's. * @param string $label Toggle button text. */ private function render_contact_form( string $card_id, string $email, string $label ): void { if ( ! ( $this->config['contact'] ?? true ) ) { return; } ?> get_state(); $key = $state['license_key'] ?? ''; $status = $state['status'] ?? ''; $session = $state['session_id'] ?? ''; $email = $state['email'] ?? ''; // Held RAW and escaped at output below. Escaping at assignment is safe today but is the // habit WordPress.org objects to: the next person to reuse this variable in a different // context inherits an already-escaped value and no way to tell. $manage_url = $this->manage_url( (string) $key ); $is_active = ( 'active' === $status && $key ); $card_id = esc_attr( $display['card_id'] ?? 'cs-lic-card' ); $card_bg = esc_attr( $display['card_bg'] ?? '#f1faf1' ); $card_border = esc_attr( $display['card_border'] ?? '#a5d6a7' ); $icon = (string) ( $display['icon'] ?? '☁' ); $price_text = (string) ( $display['price_text'] ?? esc_html( 'Subscribe to get started.' ) ); $cta_text = $display['cta_text'] ?? esc_html( 'See what\'s included' ); $explain_fn = esc_js( $display['explain_fn'] ?? 'void 0' ); /* * Connect-only: this card is being used to ATTACH this site to an account * that already exists, not to sell one. Everything about buying is left out * -- the subscribe button, the billing line, "Cancel existing subscription" * and the "Paid but key not found?" toggle -- leaving the badge, the email * connect form and the licence-key link. * * It exists because the backup plugin's "Recover from a Managed account" box * sits inside a recovery flow whose question is "which account's backups do * you want?". A PayPal button there answers a question nobody asked, and * "Cancel existing subscription" is an alarming thing to put in front of * somebody whose site is broken. * * Opt-in, so the four plugins that do not pass it render exactly as before. * It also suppresses record_paypal_checkout_intent(): no checkout is being * offered here, so recording that one was would be a false claim, and that * record is what maybe_handle_paypal_redirect() trusts before activating * anything off a return URL. */ $connect_only = ! empty( $display['connect_only'] ); // Compact drops the card's own frame and heading, for embedding inside a // surface that already has them (the SEO onboarding pricing card). Same // component, same behaviour, no second implementation. $compact = 'compact' === ( $display['variant'] ?? 'full' ); $heading = (string) ( $display['heading'] ?? ( $compact ? '' : esc_html( 'Managed subscription' ) ) ); $frame = $compact ? 'width:100%;' : 'flex:1;min-width:220px;padding:14px;border:1px solid ' . $card_border . ';border-radius:8px;background:' . $card_bg . ';'; ?>
$card_id . '-badge', 'right_text' => (string) ( $display['badge_right'] ?? '' ), ] ); ?> build_meters( $state, $card_id ) as $meter ) { $this->render_meter( $meter ); } ?>
config['boosts'] ) ) : ?>
config['boosts'] as $boost_btn ) : ?>
render_contact_form( $card_id, $email, 'Leave us a message' ); ?> config['refund_days'] ?? 0 ); $refund_days_left = 0; if ( $refund_days > 0 && ! empty( $state['subscribed_at'] ) ) { $sub_ts = strtotime( (string) $state['subscribed_at'] ); if ( $sub_ts ) { $refund_days_left = (int) max( 0, ceil( $refund_days - ( time() - $sub_ts ) / DAY_IN_SECONDS ) ); } } ?> 0 ) : ?>

config['paypal_plan_id'] ) && $this->has_paypal_config(); if ( $use_paypal_btn ) { // The site is about to offer a PayPal subscribe button to this admin. // That offer is the only server-side evidence a subscription checkout // was started here, and maybe_handle_paypal_redirect() requires it // before it will activate anything off a return URL. See that method. $this->record_paypal_checkout_intent(); } ?>

config['paypal_billing_text'] ?? 'Billed $10 USD/month · Cancel anytime' ); ?>

render_contact_form( $card_id, $email, 'Ask us a question' ); ?>

has_paypal_config() ) : ?>

⏳

$card_id, 'actionPrefix' => $this->config['action_prefix'], 'nonce' => $nonce_value, 'manageBase' => $this->manage_url(), 'licKey' => $this->get( 'license_key' ), 'usePaypal' => $this->has_paypal_config(), 'paypalPlanId' => $this->config['paypal_plan_id'] ?? '', 'paypalClientId' => $this->config['paypal_client_id'] ?? '', // Minted here, during an admin page render, because the config array is built // before init when a nonce has no user session to bind to. add_query_arg(), not // wp_nonce_url(): see paypal_create_order() for the & that broke a return URL. 'paypalReturnUrl' => add_query_arg( '_wpnonce', wp_create_nonce( self::SUB_RETURN_NONCE ), remove_query_arg( 'cs_paypal_return', $this->config['paypal_return_url'] ?? admin_url() ) ), 'product' => $this->config['product'] ?? '', // Fire-and-forget beacon so a PayPal button *click* is captured even if // the buyer never completes approval, top-of-funnel drop-off visibility. 'clickBeacon' => self::BROKER . '/checkout-start', ], $extra ); $inline_js = 'document.addEventListener("DOMContentLoaded",function(){if(window.CloudScaleLicence){window.CloudScaleLicence.init(' . wp_json_encode( $config ) . ');}});'; wp_add_inline_script( 'csdt-licence', $inline_js ); } } }