CommercePing). * * The values live in CommercePing's `shop_setting` row for this connection, the same row and the same * rules as the Shopify settings page. The plugin validates first (same rules and wording as * lib/reminder-settings.ts and lib/quiet-hours.ts) so the merchant gets an answer without a round trip; * the backend validates again and is authoritative. Calls are signed with the connection secret like every * other plugin request, so the store comes from the signature, never from the form. * * @package OrderPing */ defined( 'ABSPATH' ) || exit; const ORDERPING_SETTINGS_PATH = '/woo/settings'; const ORDERPING_SETTINGS_TIMEOUT = 8; const ORDERPING_MAX_DEADLINE = 168; const ORDERPING_MAX_DAILY_CAP = 100000; /** * Registers the save handler. */ function orderping_timing_settings_register() { add_action( 'admin_post_orderping_save_settings', 'orderping_handle_save_settings' ); } /** * Parses a whole number from form input. Anything else (blank, decimal, text, sign) is null. * * @param mixed $raw Raw value. * @return int|null */ function orderping_parse_whole( $raw ) { if ( is_int( $raw ) ) { return $raw; } if ( ! is_string( $raw ) || 1 !== preg_match( '/^[0-9]{1,6}$/', trim( $raw ) ) ) { return null; } return (int) trim( $raw ); } /** * Checks one whole-hour field against its range. Returns the value or an error message. * * @param mixed $raw Raw value. * @param string $label Field name used in the message. * @param int $min Lowest allowed. * @param int $max Highest allowed. * @return array{0:int|null,1:string|null} Value and error. */ function orderping_check_hours( $raw, $label, $min, $max ) { $n = orderping_parse_whole( $raw ); if ( null === $n ) { /* translators: %s: field name. */ return array( null, sprintf( __( '%s must be a whole number of hours.', 'commerceping' ), $label ) ); } if ( $n < $min ) { /* translators: 1: field name, 2: minimum hours. */ return array( null, sprintf( _n( '%1$s must be at least %2$d hour.', '%1$s must be at least %2$d hours.', $min, 'commerceping' ), $label, $min ) ); } if ( $n > $max ) { /* translators: 1: field name, 2: maximum hours. */ return array( null, sprintf( __( '%1$s can be at most %2$d hours.', 'commerceping' ), $label, $max ) ); } return array( $n, null ); } /** * Validates submitted settings with the backend's rules. * * @param array $in Raw input: deadline_hours, reminder_enabled, reminder_hours, second_reminder_enabled, * second_reminder_hours, quiet_enabled, quiet_start_hour, quiet_end_hour, quiet_timezone, * auto_cancel_unconfirmed, cancel_on_tap, daily_send_cap (blank or null = no limit). * @return array{ok:bool,value?:array,error?:string} */ function orderping_validate_settings( array $in ) { $max = ORDERPING_MAX_DEADLINE; list( $deadline, $err ) = orderping_check_hours( $in['deadline_hours'] ?? null, __( 'The reply deadline', 'commerceping' ), 1, $max ); if ( null !== $err ) { return array( 'ok' => false, 'error' => $err ); } list( $first, $err ) = orderping_check_hours( $in['reminder_hours'] ?? null, __( 'The reminder time', 'commerceping' ), 1, $max - 1 ); if ( null !== $err ) { return array( 'ok' => false, 'error' => $err ); } list( $second, $err ) = orderping_check_hours( $in['second_reminder_hours'] ?? null, __( 'The second reminder time', 'commerceping' ), 2, $max - 1 ); if ( null !== $err ) { return array( 'ok' => false, 'error' => $err ); } $first_on = ! empty( $in['reminder_enabled'] ); $second_on = ! empty( $in['second_reminder_enabled'] ); if ( $second_on && ! $first_on ) { return array( 'ok' => false, 'error' => __( 'Turn on the first reminder before adding a second one.', 'commerceping' ) ); } if ( $first_on && $first >= $deadline ) { return array( 'ok' => false, 'error' => __( 'The reminder must be sent earlier than the reply deadline.', 'commerceping' ) ); } if ( $first_on && $second_on ) { if ( $second >= $deadline ) { return array( 'ok' => false, 'error' => __( 'The second reminder must be sent earlier than the reply deadline.', 'commerceping' ) ); } if ( $second <= $first ) { return array( 'ok' => false, 'error' => __( 'The second reminder must come after the first reminder.', 'commerceping' ) ); } } $start = orderping_parse_whole( $in['quiet_start_hour'] ?? null ); if ( null === $start || $start < 0 || $start > 23 ) { return array( 'ok' => false, 'error' => __( 'The quiet hours start must be a whole hour from 0 to 23.', 'commerceping' ) ); } $end = orderping_parse_whole( $in['quiet_end_hour'] ?? null ); if ( null === $end || $end < 0 || $end > 23 ) { return array( 'ok' => false, 'error' => __( 'The quiet hours end must be a whole hour from 0 to 23.', 'commerceping' ) ); } $quiet_on = ! empty( $in['quiet_enabled'] ); if ( $start === $end ) { return array( 'ok' => false, 'error' => __( 'Quiet hours need a start and an end that are different. To send at any time, turn quiet hours off.', 'commerceping' ) ); } if ( $quiet_on && ( ( $start - $end + 24 ) % 24 ) < 2 ) { return array( 'ok' => false, 'error' => __( 'Quiet hours must leave at least 2 hours a day when messages can be sent.', 'commerceping' ) ); } // Whether the name is a real IANA zone is the backend's call (it uses the runtime's full zone list). $timezone = trim( (string) ( $in['quiet_timezone'] ?? '' ) ); if ( strlen( $timezone ) > 64 ) { return array( 'ok' => false, 'error' => __( 'That timezone is not recognised. Use a name like Asia/Karachi or Europe/London.', 'commerceping' ) ); } // Same rule and wording as lib/daily-cap.ts: blank means no limit, otherwise a whole number from 1 to the maximum. $cap_raw = $in['daily_send_cap'] ?? null; $cap = null; if ( null !== $cap_raw && '' !== trim( (string) ( is_array( $cap_raw ) ? 'x' : $cap_raw ) ) ) { $cap = orderping_parse_whole( $cap_raw ); if ( null === $cap || $cap < 1 || $cap > ORDERPING_MAX_DAILY_CAP ) { /* translators: %d: highest allowed daily limit. */ return array( 'ok' => false, 'error' => sprintf( __( 'The daily limit must be a whole number from 1 to %d, or empty for no limit.', 'commerceping' ), ORDERPING_MAX_DAILY_CAP ) ); } } return array( 'ok' => true, 'value' => array( 'timing' => array( 'deadline_hours' => $deadline, 'reminder_enabled' => $first_on, 'reminder_hours' => $first, 'second_reminder_enabled' => $second_on, 'second_reminder_hours' => $second, ), 'quiet' => array( 'enabled' => $quiet_on, 'start_hour' => $start, 'end_hour' => $end, 'timezone' => '' === $timezone ? null : $timezone, ), 'auto_cancel_unconfirmed' => ! empty( $in['auto_cancel_unconfirmed'] ), 'cancel_on_tap' => ! empty( $in['cancel_on_tap'] ), 'daily_send_cap' => $cap, ), ); } /** * Signed POST to a backend endpoint. Never throws. * * @param string $path Logical path, e.g. /woo/settings. * @param array $payload Body fields; contract_version is added. * @return array{status:int,body:array} Status 0 means the backend was not reached. */ function orderping_signed_post( $path, array $payload ) { $conn = orderping_connection(); if ( null === $conn ) { return array( 'status' => 0, 'body' => array() ); } $body = wp_json_encode( $payload + array( 'contract_version' => ORDERPING_CONTRACT_VERSION ) ); $timestamp = time(); $nonce = bin2hex( random_bytes( 16 ) ); $response = wp_remote_post( orderping_backend_url() . $path, array( 'timeout' => ORDERPING_SETTINGS_TIMEOUT, 'redirection' => 0, 'headers' => array( 'Content-Type' => 'application/json', 'X-OP-Connection' => $conn['id'], 'X-OP-Timestamp' => (string) $timestamp, 'X-OP-Nonce' => $nonce, 'X-OP-Signature' => orderping_sign( $conn['secret'], 'site-to-cp', 'POST', $path, $timestamp, $nonce, $body ), ), 'body' => $body, ) ); if ( is_wp_error( $response ) ) { return array( 'status' => 0, 'body' => array() ); } $decoded = json_decode( (string) wp_remote_retrieve_body( $response ), true ); return array( 'status' => (int) wp_remote_retrieve_response_code( $response ), 'body' => is_array( $decoded ) ? $decoded : array(), ); } /** * Signed POST to the backend settings endpoint. Never throws. * * @param array $payload Body (settings_get or settings_save). * @return array{status:int,body:array} Status 0 means the backend was not reached. */ function orderping_settings_request( array $payload ) { return orderping_signed_post( ORDERPING_SETTINGS_PATH, $payload ); } /** * Current settings from the backend, or null when they cannot be read right now. * * @return array|null */ function orderping_fetch_settings() { $result = orderping_settings_request( array( 'type' => 'settings_get' ) ); if ( 200 !== $result['status'] || ! isset( $result['body']['settings']['timing'], $result['body']['settings']['quiet'] ) ) { return null; } return $result['body']['settings']; } /** * Settings-form Save handler: capability, nonce, local validation, then the signed save. */ function orderping_handle_save_settings() { if ( ! current_user_can( 'manage_woocommerce' ) ) { wp_die( esc_html__( 'You do not have permission to do this.', 'commerceping' ), 403 ); } check_admin_referer( 'orderping_save_settings' ); if ( null === orderping_connection() ) { orderping_redirect_to_settings( 'complete_failed' ); } // Nonce verified above; every value is range-checked by orderping_validate_settings(). // phpcs:disable WordPress.Security.NonceVerification.Missing $post = wp_unslash( $_POST ); $flag = static function ( $key ) use ( $post ) { return isset( $post[ $key ] ) && '1' === $post[ $key ]; }; $checked = orderping_validate_settings( array( 'deadline_hours' => $post['orderping_deadline_hours'] ?? null, 'reminder_enabled' => $flag( 'orderping_reminder_enabled' ), 'reminder_hours' => $post['orderping_reminder_hours'] ?? null, 'second_reminder_enabled' => $flag( 'orderping_second_reminder_enabled' ), 'second_reminder_hours' => $post['orderping_second_reminder_hours'] ?? null, 'quiet_enabled' => $flag( 'orderping_quiet_enabled' ), 'quiet_start_hour' => $post['orderping_quiet_start_hour'] ?? null, 'quiet_end_hour' => $post['orderping_quiet_end_hour'] ?? null, 'quiet_timezone' => isset( $post['orderping_quiet_timezone'] ) ? sanitize_text_field( (string) $post['orderping_quiet_timezone'] ) : '', 'auto_cancel_unconfirmed' => $flag( 'orderping_auto_cancel_unconfirmed' ), 'cancel_on_tap' => $flag( 'orderping_cancel_on_tap' ), 'daily_send_cap' => $post['orderping_daily_send_cap'] ?? '', ) ); // phpcs:enable WordPress.Security.NonceVerification.Missing if ( ! $checked['ok'] ) { orderping_remember_settings_error( $checked['error'] ); orderping_redirect_to_settings( 'settings_invalid' ); } $payload = array( 'type' => 'settings_save' ) + $checked['value']; // The block is sent only when the form had it (the backend leaves the feature alone otherwise). if ( isset( $post['orderping_cod_prepay_reminder_hours'] ) ) { $cod = orderping_validate_cod_prepay( array( 'enabled' => $flag( 'orderping_cod_prepay_enabled' ), 'incentive' => $post['orderping_cod_prepay_incentive'] ?? '', 'reminder_hours' => $post['orderping_cod_prepay_reminder_hours'], ) ); if ( ! $cod['ok'] ) { orderping_remember_settings_error( $cod['error'] ); orderping_redirect_to_settings( 'settings_invalid' ); } $payload['cod_prepay'] = $cod['value']; } $result = orderping_settings_request( $payload ); if ( 200 === $result['status'] ) { orderping_redirect_to_settings( 'settings_saved' ); } if ( 422 === $result['status'] && isset( $result['body']['message'] ) && is_string( $result['body']['message'] ) ) { orderping_remember_settings_error( $result['body']['message'] ); orderping_redirect_to_settings( 'settings_invalid' ); } orderping_redirect_to_settings( 'settings_failed' ); } /** * Keeps one validation message for the next page load of this user (the redirect carries only a code). * * @param string $message Plain-wording problem. */ function orderping_remember_settings_error( $message ) { set_transient( 'orderping_settings_error_' . get_current_user_id(), $message, 60 ); } /** * Returns and clears the remembered validation message. * * @return string */ function orderping_take_settings_error() { $key = 'orderping_settings_error_' . get_current_user_id(); $message = get_transient( $key ); delete_transient( $key ); return is_string( $message ) ? $message : ''; } /** * Prints the hour options of a quiet-hours select. * * @param int $selected Selected hour. */ function orderping_hour_options( $selected ) { for ( $h = 0; $h < 24; $h++ ) { printf( '', (int) $h, selected( (int) $selected, $h, false ), esc_html( sprintf( '%02d:00', $h ) ) ); } } /** * Renders the settings form for a connected store. The caller checked the capability. */ function orderping_render_timing_settings() { $settings = orderping_fetch_settings(); echo '

' . esc_html__( 'Reminders, quiet hours and cancelling', 'commerceping' ) . '

'; if ( null === $settings ) { echo '

' . esc_html__( 'Your settings could not be loaded from CommercePing right now. Reload this page in a moment.', 'commerceping' ) . '

'; return; } $t = $settings['timing']; $q = $settings['quiet']; $zone = wp_timezone_string(); $sample_tz = false !== strpos( $zone, '/' ) ? $zone : 'Asia/Karachi'; ?>