id, self::PAGE ); } /** Notices only on the plugin's own screens: the rest of the admin is not its business. */ public function notices(): void { if ( ! current_user_can( 'manage_options' ) ) { return; } $screen = function_exists( 'get_current_screen' ) ? get_current_screen() : null; // Cryptoway refuses the keys from this server's address: said wherever the owner looks first. $refused = (int) get_option( ApiClient::IP_REFUSED, 0 ); if ( Settings::keys_present() && $refused > time() - DAY_IN_SECONDS && ( ( $screen instanceof \WP_Screen && in_array( $screen->id, [ 'dashboard', 'plugins' ], true ) ) || $this->is_own_screen() ) ) { echo '

' . esc_html( sprintf( /* translators: %s: date and time of the last refusal */ __( 'Cryptoway refused this site\'s keys from this server\'s address ("Invalid IP"), last at %s.', 'cryptoway-button' ), self::format_time( $refused ) ) ) . ' ' . esc_html__( 'Until it is fixed, visitors cannot start payments, and payments already started wait for their check. To fix it, in your Cryptoway account open the project and add to Allowed IPs the address this server uses to go online, or its range: your hosting provider can tell you which. Then save the settings here again: this notice goes away when Cryptoway accepts the keys.', 'cryptoway-button' ) . '

'; } // Right after activation the owner is on the Plugins screen: one pointer there until the keys are in. if ( $screen instanceof \WP_Screen && 'plugins' === $screen->id ) { if ( ! Settings::keys_present() ) { printf( '

%s

', sprintf( /* translators: %s: link to the settings screen */ esc_html__( 'Cryptoway Payment is active. Add the Project ID and Secret key in %s to start taking payments.', 'cryptoway-button' ), '' . esc_html__( 'Settings', 'cryptoway-button' ) . '' ) ); } return; } if ( ! $this->is_own_screen() ) { return; } $notice = get_transient( self::NOTICE_TRANSIENT ); if ( is_array( $notice ) && ! empty( $notice['text'] ) ) { delete_transient( self::NOTICE_TRANSIENT ); printf( '

%2$s

', esc_attr( in_array( $notice['type'] ?? '', [ 'success', 'error', 'warning', 'info' ], true ) ? $notice['type'] : 'info' ), esc_html( (string) $notice['text'] ) ); } if ( ! Settings::keys_present() ) { $on_settings = str_contains( (string) $screen->id, self::PAGE_SETTINGS ); printf( '

%s

', $on_settings ? esc_html__( 'Add the Project ID and Secret key below to start taking payments.', 'cryptoway-button' ) : sprintf( /* translators: %s: link to the settings screen */ esc_html__( 'Add the Project ID and Secret key in %s to start taking payments.', 'cryptoway-button' ), '' . esc_html__( 'Settings', 'cryptoway-button' ) . '' ) ); } elseif ( '' === CallbackKey::stored() ) { echo '

' . esc_html__( 'The callback key has not been received from Cryptoway yet. Payment notifications cannot be verified until it is: save the settings again to retry.', 'cryptoway-button' ) . '

'; } } private static function set_notice( string $type, string $text ): void { set_transient( self::NOTICE_TRANSIENT, [ 'type' => $type, 'text' => $text ], MINUTE_IN_SECONDS ); } // ------------------------------------------------------------------ settings public function register_settings(): void { register_setting( self::GROUP, Settings::OPTION, [ 'type' => 'array', 'sanitize_callback' => [ $this, 'sanitize_settings' ], 'default' => Settings::DEFAULTS, ] ); register_setting( self::GROUP_TEXTS, Settings::TEXTS_OPTION, [ 'type' => 'array', 'sanitize_callback' => [ $this, 'sanitize_texts' ], 'default' => [], ] ); } /** * Visitor texts: only known keys, plain text, and only where the owner wrote something else. * * @param mixed $input * @return array */ public function sanitize_texts( $input ): array { $out = []; $catalogue = Texts::catalogue(); foreach ( (array) $input as $key => $value ) { if ( ! is_string( $key ) || ! isset( $catalogue[ $key ] ) || ! is_scalar( $value ) ) { continue; } $value = trim( sanitize_text_field( (string) $value ) ); if ( '' !== $value && $value !== $catalogue[ $key ]['default'] ) { $out[ $key ] = $value; } } return $out; } /** * Clean the submitted settings. A blank Secret key keeps the stored one; the callback * key is fetched from Cryptoway with the keys about to be saved. * * @param mixed $input */ public function sanitize_settings( $input ): array { $input = is_array( $input ) ? $input : []; // Not the settings form: the plugin's own write of the stored array (copying the keys, the // callback key), or WordPress sanitizing the form's result a second time when the option is // first created. The form never sends a callback key. Only types are checked: nothing is // switched on or off and Cryptoway is not asked again. if ( array_key_exists( 'callback_key', $input ) ) { return self::clean_stored( $input ); } $current = Settings::all(); $out = $current; $out['api_key_id'] = self::clean_key( (string) ( $input['api_key_id'] ?? '' ) ); $secret = self::clean_key( (string) ( $input['api_key_secret'] ?? '' ) ); if ( '' !== $secret ) { $out['api_key_secret'] = $secret; } $lifetime = (int) ( $input['lifetime'] ?? Settings::DEFAULT_LIFETIME ); $out['lifetime'] = max( Settings::MIN_LIFETIME, min( Settings::MAX_LIFETIME, $lifetime > 0 ? $lifetime : Settings::DEFAULT_LIFETIME ) ); $currency = strtoupper( sanitize_text_field( (string) ( $input['currency'] ?? 'USD' ) ) ); $out['currency'] = in_array( $currency, Settings::CURRENCIES, true ) ? $currency : 'USD'; $out['email_enabled'] = 'yes' === ( $input['email_enabled'] ?? '' ) ? 'yes' : 'no'; $out['email_to'] = sanitize_email( (string) ( $input['email_to'] ?? '' ) ); // Coins for the on-site picker: only ids Cryptoway lists; all of them ticked is stored as "no restriction". $available = Coins::available(); $ticked = []; foreach ( (array) ( $input['coins'] ?? [] ) as $symbol ) { $symbol = Coins::normalise( sanitize_text_field( (string) $symbol ) ); if ( in_array( $symbol, $available, true ) ) { $ticked[] = $symbol; } } $ticked = array_values( array_unique( $ticked ) ); $out['coins'] = count( $ticked ) >= count( $available ) ? [] : $ticked; unset( $out['texts'] ); // Visitor texts moved to their own option (2026-10-12). $keys_changed = $out['api_key_id'] !== $current['api_key_id'] || $out['api_key_secret'] !== $current['api_key_secret']; if ( '' === $out['api_key_id'] || '' === $out['api_key_secret'] ) { $out['callback_key'] = ''; return $out; } // The callback key signs notifications: it comes from the project, never from a field. $fetched = CallbackKey::fetch( new ApiClient( $out['api_key_id'], $out['api_key_secret'] ) ); if ( $fetched['ok'] ) { $out['callback_key'] = $fetched['key']; self::set_notice( 'success', __( 'Cryptoway accepted the keys. Payment notifications are set up.', 'cryptoway-button' ) ); } else { if ( $keys_changed ) { $out['callback_key'] = ''; } self::set_notice( 'error', sprintf( /* translators: %s: reason */ __( 'Cryptoway did not confirm the keys: %s', 'cryptoway-button' ), $fetched['error'] ) ); } // The coin list for on-site payments, refreshed along with the keys. if ( $fetched['ok'] ) { Coins::refresh_available(); } return $out; } private static function clean_key( string $value ): string { return trim( sanitize_text_field( $value ) ); } /** The stored settings array, type by type, as it is: for the plugin's own writes. */ private static function clean_stored( array $input ): array { $out = array_merge( Settings::DEFAULTS, array_intersect_key( $input, Settings::DEFAULTS ) ); foreach ( [ 'api_key_id', 'api_key_secret', 'callback_key' ] as $key ) { $out[ $key ] = self::clean_key( is_scalar( $out[ $key ] ) ? (string) $out[ $key ] : '' ); } $lifetime = (int) $out['lifetime']; $out['lifetime'] = max( Settings::MIN_LIFETIME, min( Settings::MAX_LIFETIME, $lifetime > 0 ? $lifetime : Settings::DEFAULT_LIFETIME ) ); $currency = strtoupper( is_scalar( $out['currency'] ) ? (string) $out['currency'] : '' ); $out['currency'] = in_array( $currency, Settings::CURRENCIES, true ) ? $currency : 'USD'; $out['email_enabled'] = 'yes' === $out['email_enabled'] ? 'yes' : 'no'; $out['email_to'] = sanitize_email( is_scalar( $out['email_to'] ) ? (string) $out['email_to'] : '' ); $out['coins'] = is_array( $out['coins'] ) ? array_values( array_filter( array_map( static fn( $s ): string => Coins::normalise( is_scalar( $s ) ? (string) $s : '' ), $out['coins'] ) ) ) : []; return $out; } public function render_settings(): void { if ( ! current_user_can( 'manage_options' ) ) { return; } $s = Settings::all(); $sources = Settings::importable_sources(); $log = array_reverse( Log::lines() ); $has_keys = Settings::keys_present(); ?>

$text ) : ?>
0 ) { $payment = Payment::get( $id ); if ( $payment ) { $this->render_payment( $payment ); return; } } $table = new PaymentsTable(); $table->prepare_items(); ?>


views(); ?>
search_box( __( 'Search payments', 'cryptoway-button' ), 'cwbtn' ); $table->display(); ?>
$payment->status_label_now(), __( 'For', 'cryptoway-button' ) => $payment->title(), __( 'Amount', 'cryptoway-button' ) => Amounts::fiat( $payment->amount(), $payment->currency() ), ]; $rows = array_merge( $rows, $manager->amount_rows() ); if ( '' !== $payment->symbol() ) { $rows[ __( 'Coin', 'cryptoway-button' ) ] = Coins::label( $payment->symbol() ); } if ( '' !== $payment->tx_hash() ) { $rows[ __( 'Transaction hash', 'cryptoway-button' ) ] = $payment->tx_hash(); } if ( '' !== $payment->address() ) { $rows[ __( 'Address', 'cryptoway-button' ) ] = $payment->address(); } if ( '' !== $payment->invoice_id() ) { $rows[ __( 'Invoice', 'cryptoway-button' ) ] = $payment->invoice_id(); } if ( '' !== $payment->deadline() ) { $rows[ __( 'Pay before', 'cryptoway-button' ) ] = self::format_time( (int) strtotime( $payment->deadline() ) ); } $rows[ __( 'Started', 'cryptoway-button' ) ] = self::format_time( $payment->created_at() ); $rows[ __( 'Where', 'cryptoway-button' ) ] = Button::PAY_ONSITE === $payment->mode() ? __( 'On the page', 'cryptoway-button' ) : __( 'Cryptoway payment page', 'cryptoway-button' ); $source = $payment->source_post(); if ( $source > 0 ) { $rows[ __( 'Page', 'cryptoway-button' ) ] = (string) get_the_title( $source ); } foreach ( $payment->payer() as $field => $value ) { $label = match ( $field ) { 'name' => __( 'Name', 'cryptoway-button' ), 'email' => __( 'E-mail', 'cryptoway-button' ), default => __( 'Message', 'cryptoway-button' ), }; $rows[ $label ] = $value; } $paid_content = Button::AFTER_CONTENT === $payment->after(); $until = $paid_content ? Button::access_until( $payment ) : 0; if ( $paid_content ) { $rows[ __( 'Access to the paid content', 'cryptoway-button' ) ] = match ( true ) { '' !== $payment->meta( Payment::META_ACCESS_REVOKED ) => __( 'Closed by hand', 'cryptoway-button' ), $until > time() => sprintf( /* translators: %s: date and time */ __( 'Open until %s', 'cryptoway-button' ), self::format_time( $until ) ), $until > 0 => __( 'The access period is over', 'cryptoway-button' ), default => __( 'Not open', 'cryptoway-button' ), }; } $check_url = wp_nonce_url( admin_url( 'admin-post.php?action=cryptoway_button_check&payment=' . $payment->id() ), 'cryptoway_button_check_' . $payment->id() ); $access_url = static fn( string $op ): string => wp_nonce_url( admin_url( 'admin-post.php?action=cryptoway_button_access&op=' . $op . '&payment=' . $payment->id() ), 'cryptoway_button_access_' . $payment->id() ); $delete_url = wp_nonce_url( admin_url( 'admin-post.php?action=cryptoway_button_delete&payment=' . $payment->id() ), 'cryptoway_button_delete_' . $payment->id() ); $can_grant = $paid_content && ! $payment->is_settled() && '' !== ( $payment->payer()['email'] ?? '' ) && in_array( $payment->status(), self::GRANTABLE, true ); ?>

id() ) ); ?>


$value ) : ?>
needs_review() ) : ?>

invoice_id() && Settings::keys_present() ) : ?> meta( Payment::META_ACCESS_REVOKED ) ) : ?> time() ) : ?> status(), Payment::OPEN, true ) ) : ?>

history() ); ?>

sync_status_from_api( 'manual' ); self::set_notice( 'info', __( 'The payment was checked with Cryptoway. See the history for the result.', 'cryptoway-button' ) ); wp_safe_redirect( $payment->admin_url() ); exit; } wp_safe_redirect( self::payments_url() ); exit; } public function handle_import(): void { $source = isset( $_GET['source'] ) ? sanitize_key( wp_unslash( $_GET['source'] ) ) : ''; // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- verified on the next line. if ( ! current_user_can( 'manage_options' ) || ! check_admin_referer( 'cryptoway_button_import_' . $source ) ) { wp_die( esc_html__( 'Not allowed.', 'cryptoway-button' ) ); } if ( Settings::import_keys( $source ) ) { $fetched = CallbackKey::refresh(); if ( $fetched['ok'] ) { Coins::refresh_available(); self::set_notice( 'success', __( 'The keys were copied and Cryptoway accepted them.', 'cryptoway-button' ) ); } else { self::set_notice( 'warning', sprintf( /* translators: %s: reason */ __( 'The keys were copied, but Cryptoway did not confirm them: %s', 'cryptoway-button' ), $fetched['error'] ) ); } } else { self::set_notice( 'error', __( 'No keys were found to copy.', 'cryptoway-button' ) ); } wp_safe_redirect( self::settings_url() ); exit; } public function handle_clear_log(): void { if ( ! current_user_can( 'manage_options' ) || ! check_admin_referer( 'cryptoway_button_clear_log' ) ) { wp_die( esc_html__( 'Not allowed.', 'cryptoway-button' ) ); } Log::clear(); wp_safe_redirect( self::settings_url() ); exit; } /** * Paid content by hand: close the access (a refund), open it again, or open it for a payment that * was not confirmed automatically (late, held) once the owner has settled it with the payer. */ public function handle_access(): void { $id = isset( $_GET['payment'] ) ? absint( wp_unslash( $_GET['payment'] ) ) : 0; // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- verified on the next line. if ( ! current_user_can( 'manage_options' ) || ! check_admin_referer( 'cryptoway_button_access_' . $id ) ) { wp_die( esc_html__( 'Not allowed.', 'cryptoway-button' ) ); } $op = isset( $_GET['op'] ) ? sanitize_key( wp_unslash( $_GET['op'] ) ) : ''; $payment = Payment::get( $id ); if ( ! $payment || Button::AFTER_CONTENT !== $payment->after() ) { wp_safe_redirect( self::payments_url() ); exit; } $who = wp_get_current_user()->display_name; if ( 'close' === $op ) { $payment->set_meta( Payment::META_ACCESS_REVOKED, (string) time() ); /* translators: %s: user name */ $payment->add_history( sprintf( __( 'Access to the paid content closed by %s.', 'cryptoway-button' ), $who ) ); self::set_notice( 'success', __( 'The access is closed. The payer\'s link no longer opens the content.', 'cryptoway-button' ) ); } elseif ( 'reopen' === $op ) { $payment->delete_meta( Payment::META_ACCESS_REVOKED ); /* translators: %s: user name */ $payment->add_history( sprintf( __( 'Access to the paid content opened again by %s.', 'cryptoway-button' ), $who ) ); self::set_notice( 'success', __( 'The access is open again for the rest of its period.', 'cryptoway-button' ) ); } elseif ( 'grant' === $op && ! $payment->is_settled() && in_array( $payment->status(), self::GRANTABLE, true ) ) { $payment->delete_meta( Payment::META_ACCESS_REVOKED ); $payment->set_meta( Payment::META_ACCESS_GRANTED, (string) time() ); // The link goes out now: a later confirmation must not send it a second time. $sent = $payment->meta_array( Payment::META_EMAILED ); if ( ! in_array( 'payer', $sent, true ) ) { $sent[] = 'payer'; $payment->set_meta( Payment::META_EMAILED, $sent ); } /* translators: %s: user name */ $payment->add_history( sprintf( __( 'Access to the paid content opened by hand by %s; the link was e-mailed to the payer.', 'cryptoway-button' ), $who ) ); Email::access_link( $payment ); self::set_notice( 'success', __( 'The access is open and the link was e-mailed to the payer.', 'cryptoway-button' ) ); } wp_safe_redirect( $payment->admin_url() ); exit; } /** Delete a closed payment for good (an open one could still receive money). */ public function handle_delete(): void { $id = isset( $_GET['payment'] ) ? absint( wp_unslash( $_GET['payment'] ) ) : 0; // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- verified on the next line. if ( ! current_user_can( 'manage_options' ) || ! check_admin_referer( 'cryptoway_button_delete_' . $id ) ) { wp_die( esc_html__( 'Not allowed.', 'cryptoway-button' ) ); } $payment = Payment::get( $id ); if ( $payment && ! in_array( $payment->status(), Payment::OPEN, true ) && $payment->delete() ) { /* translators: %d: payment number */ self::set_notice( 'success', sprintf( __( 'Payment #%d was deleted.', 'cryptoway-button' ), $id ) ); } wp_safe_redirect( self::payments_url() ); exit; } }