sanitize_text_field( self::field( $posted, 'key_prefix' ) ), 'base_url' => esc_url_raw( self::field( $posted, 'base_url' ) ), 'pixel_url' => esc_url_raw( self::field( $posted, 'pixel_url' ) ), 'program_id' => (int) self::field( $posted, 'program_id' ), 'store_ref' => sanitize_text_field( self::field( $posted, 'store_ref' ) ), 'test_mode' => empty( $posted['test_mode'] ) ? 0 : 1, 'enabled' => empty( $posted['enabled'] ) ? 0 : 1, 'beacon' => empty( $posted['beacon'] ) ? 0 : 1, 'pixel' => empty( $posted['pixel'] ) ? 0 : 1, 'report_all' => empty( $posted['report_all'] ) ? 0 : 1, 'cookie_days' => max( 1, min( 365, (int) self::field( $posted, 'cookie_days' ) ) ), 'timeout' => max( 1, min( 30, (int) self::field( $posted, 'timeout' ) ) ), 'paid_statuses' => self::posted_statuses( $posted ), ); // A secret is only overwritten when a new one is typed. The field is // rendered empty with a placeholder, so saving the page without // touching it must not wipe the key. $api_key = trim( self::field( $posted, 'api_key' ) ); if ( '' !== $api_key ) { $values['api_key'] = sanitize_text_field( $api_key ); } $secret = trim( self::field( $posted, 'signing_secret' ) ); if ( '' !== $secret ) { $values['signing_secret'] = sanitize_text_field( $secret ); } Affiliatant_Settings::save( $values ); self::back( 'saved' ); } /** * The connection test. * * It round-trips a REAL test conversion against a reserved reference, so * it proves the key, the base URL, the programme id, the signature and * the network in one go rather than only that a hostname resolves. * * is_test is always set, whatever the test-mode setting says, so pressing * a button on a settings page can never create a real commission. * * @return void */ public static function handle_test() { self::guard( 'affiliatant_test' ); if ( ! Affiliatant_Settings::configured() ) { self::back( 'test_failed', __( 'Enter an API key and a programme id first.', 'affiliatant-for-woocommerce' ) ); } $reference = 'AFFTEST-' . gmdate( 'ymd-His' ); try { $payload = Affiliatant_Payload::connection_test( (int) Affiliatant_Settings::get( 'program_id' ), (string) Affiliatant_Settings::get( 'store_ref' ), function_exists( 'get_woocommerce_currency' ) ? get_woocommerce_currency() : 'GBP', $reference, gmdate( 'c' ) ); $response = Affiliatant_Settings::client()->createConversion( $payload, $payload['event_id'] ); $detail = $reference; if ( isset( $response['status'] ) && is_string( $response['status'] ) ) { $detail .= ' (' . $response['status'] . ')'; } Affiliatant_Settings::note_event( 'connection_test', $payload['event_id'], 0 ); self::back( 'test_ok', $detail ); } catch ( \Throwable $e ) { // The message is Affiliatant's own error text. It never contains // the key: the SDK keeps it out of exception messages and URLs. Affiliatant_Settings::note_error( $e->getMessage(), 0 ); self::back( 'test_failed', $e->getMessage() ); } } /** * Drain the queue now. * * @return void */ public static function handle_retry() { self::guard( 'affiliatant_retry' ); $result = Affiliatant_Sender::drain( 50, 20 ); self::back( 'retried', sprintf( /* translators: 1: events sent, 2: events that failed */ __( '%1$d sent, %2$d still failing.', 'affiliatant-for-woocommerce' ), (int) $result['sent'], (int) $result['failed'] ) ); } /** * Delete the failed rows. * * @return void */ public static function handle_clear() { self::guard( 'affiliatant_clear' ); $deleted = Affiliatant_Queue::clear_failed(); self::back( 'cleared', sprintf( /* translators: %d: rows removed */ __( '%d failed event removed.', 'affiliatant-for-woocommerce' ), (int) $deleted ) ); } /** * Attach a referral to an order by hand, and report it. * * The back-office case: the order was taken over the telephone, or * created in wp-admin, so there was never a browser with an aftid in it. * * This is a form on THIS page rather than a field on the order screen, * because WooCommerce's own save hook for the order screen, * woocommerce_process_shop_order_meta, RETURNS EARLY WITHOUT FIRING WHEN * HPOS IS ENABLED. Hanging the save off it would work on a legacy install * and silently do nothing on a modern one, which is the worst of the two. * * @return void */ public static function handle_set_click() { self::guard( 'affiliatant_set_click' ); $order_id = isset( $_POST['order_id'] ) ? (int) $_POST['order_id'] : 0; // phpcs:ignore WordPress.Security.NonceVerification.Missing -- guard() ran check_admin_referer. $click_id = isset( $_POST['click_id'] ) ? Affiliatant_Capture::clean_click_id( sanitize_text_field( wp_unslash( $_POST['click_id'] ) ) ) : ''; // phpcs:ignore WordPress.Security.NonceVerification.Missing $order = $order_id > 0 ? wc_get_order( $order_id ) : false; if ( ! is_a( $order, 'WC_Order' ) ) { self::back( 'manual_failed', __( 'No such order.', 'affiliatant-for-woocommerce' ) ); } if ( '' === $click_id ) { self::back( 'manual_failed', __( 'That is not a valid referral id.', 'affiliatant-for-woocommerce' ) ); } Affiliatant_Order::set_click( $order, $click_id ); $queued = Affiliatant_Reporter::report_sale( $order ); self::back( 'manual_ok', $queued ? sprintf( /* translators: %d: order id */ __( 'Order %d is now attributed and has been queued.', 'affiliatant-for-woocommerce' ), $order_id ) : sprintf( /* translators: %d: order id */ __( 'Order %d is now attributed. It was already reported, so nothing new was queued.', 'affiliatant-for-woocommerce' ), $order_id ) ); } // ----------------------------------------------------------------- // The page // ----------------------------------------------------------------- /** * Render the settings page. * * @return void */ public static function render() { if ( ! current_user_can( self::CAP ) ) { wp_die( esc_html__( 'You do not have permission to see this page.', 'affiliatant-for-woocommerce' ) ); } Affiliatant_Settings::flush(); $settings = Affiliatant_Settings::all(); $counts = Affiliatant_Queue::counts(); $events = Affiliatant_Queue::recent( 20 ); $action = esc_url( admin_url( 'admin-post.php' ) ); echo '

' . esc_html__( 'Affiliatant', 'affiliatant-for-woocommerce' ) . '

'; self::notice(); // ---- Status panel first, because it answers the only question a // merchant opens this page with: is it working? echo '

' . esc_html__( 'Status', 'affiliatant-for-woocommerce' ) . '

'; echo ''; self::status_row( __( 'Configured', 'affiliatant-for-woocommerce' ), Affiliatant_Settings::configured() ? __( 'Yes', 'affiliatant-for-woocommerce' ) : __( 'No. An API key and a programme id are both needed.', 'affiliatant-for-woocommerce' ) ); self::status_row( __( 'Reporting', 'affiliatant-for-woocommerce' ), Affiliatant_Settings::active() ? __( 'On', 'affiliatant-for-woocommerce' ) : __( 'Off. Nothing is being sent.', 'affiliatant-for-woocommerce' ) ); self::status_row( __( 'API key', 'affiliatant-for-woocommerce' ), Affiliatant_Settings::configured() ? Affiliatant_Settings::client()->keyHint() : '-' ); self::status_row( __( 'Mode', 'affiliatant-for-woocommerce' ), Affiliatant_Settings::key_is_test() ? __( 'Test, because the key is a test key. Every conversion is flagged as test data and will not appear in a live report.', 'affiliatant-for-woocommerce' ) : ( Affiliatant_Settings::test_mode() ? __( 'Test, because test mode is switched on below. Every conversion is flagged as test data.', 'affiliatant-for-woocommerce' ) : __( 'Live', 'affiliatant-for-woocommerce' ) ) ); self::status_row( __( 'Order storage', 'affiliatant-for-woocommerce' ), self::hpos_enabled() ? __( 'High-Performance Order Storage. Declared compatible.', 'affiliatant-for-woocommerce' ) : __( 'Legacy post storage. Declared compatible with HPOS as well.', 'affiliatant-for-woocommerce' ) ); self::status_row( __( 'Background sending', 'affiliatant-for-woocommerce' ), function_exists( 'as_enqueue_async_action' ) ? __( 'Action Scheduler. No outbound request happens inside a checkout.', 'affiliatant-for-woocommerce' ) : __( 'WP-Cron only. Action Scheduler is missing, so the first send attempt happens in the request that created the event.', 'affiliatant-for-woocommerce' ) ); self::status_row( __( 'Subscriptions', 'affiliatant-for-woocommerce' ), Affiliatant_Subscriptions::available() ? __( 'Active. Renewals are reported as kind=recurring.', 'affiliatant-for-woocommerce' ) : __( 'Not active.', 'affiliatant-for-woocommerce' ) ); self::status_row( __( 'Queue', 'affiliatant-for-woocommerce' ), sprintf( /* translators: 1: queued, 2: sent, 3: failed */ __( '%1$d queued, %2$d sent, %3$d failed', 'affiliatant-for-woocommerce' ), (int) $counts['queued'], (int) $counts['sent'], (int) $counts['failed'] ) ); $last_event = is_array( $settings['last_event'] ) ? $settings['last_event'] : array(); self::status_row( __( 'Last event', 'affiliatant-for-woocommerce' ), empty( $last_event['at'] ) ? __( 'Nothing yet.', 'affiliatant-for-woocommerce' ) : sprintf( '%s, order %d, %s', isset( $last_event['action'] ) ? (string) $last_event['action'] : '', isset( $last_event['order_id'] ) ? (int) $last_event['order_id'] : 0, self::when( (int) $last_event['at'] ) ) ); $last_error = is_array( $settings['last_error'] ) ? $settings['last_error'] : array(); self::status_row( __( 'Last error', 'affiliatant-for-woocommerce' ), empty( $last_error['at'] ) ? __( 'None.', 'affiliatant-for-woocommerce' ) : sprintf( '%s (%s)', (string) $last_error['message'], self::when( (int) $last_error['at'] ) ) ); self::status_row( __( 'Retry window', 'affiliatant-for-woocommerce' ), sprintf( /* translators: %d: hours */ __( 'Eight attempts over about %d hours, then the event is left failed for a human.', 'affiliatant-for-woocommerce' ), (int) round( Affiliatant_Retry::window() / HOUR_IN_SECONDS ) ) ); echo '
'; // ---- Buttons. echo '

'; self::button( $action, 'affiliatant_test', __( 'Send a test conversion', 'affiliatant-for-woocommerce' ), 'button button-primary' ); self::button( $action, 'affiliatant_retry', __( 'Retry now', 'affiliatant-for-woocommerce' ), 'button' ); self::button( $action, 'affiliatant_clear', __( 'Clear failed', 'affiliatant-for-woocommerce' ), 'button' ); echo '

'; // ---- Settings. echo '

' . esc_html__( 'Settings', 'affiliatant-for-woocommerce' ) . '

'; echo '
'; // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- escaped above. wp_nonce_field( 'affiliatant_save' ); echo ''; echo ''; self::password_field( 'api_key', __( 'Secret API key', 'affiliatant-for-woocommerce' ), (string) $settings['api_key'], __( 'Left blank, the stored key is kept.', 'affiliatant-for-woocommerce' ) ); self::text_field( 'key_prefix', __( 'Key prefix', 'affiliatant-for-woocommerce' ), (string) $settings['key_prefix'], __( 'The visible part of the key. Not a secret. Only needed for the receipt pixel.', 'affiliatant-for-woocommerce' ) ); self::password_field( 'signing_secret', __( 'Signing secret', 'affiliatant-for-woocommerce' ), (string) $settings['signing_secret'], __( 'Signs what this plugin sends. Left blank, the stored secret is kept.', 'affiliatant-for-woocommerce' ) ); self::text_field( 'program_id', __( 'Programme id', 'affiliatant-for-woocommerce' ), (string) $settings['program_id'], '' ); self::text_field( 'store_ref', __( 'Store reference', 'affiliatant-for-woocommerce' ), (string) $settings['store_ref'], __( 'Your own reference for this shop. It is part of every event id, so changing it after go-live makes every future event look new.', 'affiliatant-for-woocommerce' ) ); self::text_field( 'base_url', __( 'API base URL', 'affiliatant-for-woocommerce' ), (string) $settings['base_url'], '' ); self::text_field( 'timeout', __( 'Timeout, seconds', 'affiliatant-for-woocommerce' ), (string) $settings['timeout'], __( 'Kept short on purpose. With Action Scheduler present this never runs inside a checkout.', 'affiliatant-for-woocommerce' ) ); self::text_field( 'cookie_days', __( 'Referral lifetime, days', 'affiliatant-for-woocommerce' ), (string) $settings['cookie_days'], '' ); self::checkbox( 'enabled', __( 'Report to Affiliatant', 'affiliatant-for-woocommerce' ), (int) $settings['enabled'], __( 'Off, nothing is captured and nothing is sent.', 'affiliatant-for-woocommerce' ) ); self::checkbox( 'test_mode', __( 'Test mode', 'affiliatant-for-woocommerce' ), (int) $settings['test_mode'], __( 'Flags every conversion as test data at Affiliatant.', 'affiliatant-for-woocommerce' ) ); self::checkbox( 'beacon', __( 'Page-cache beacon', 'affiliatant-for-woocommerce' ), (int) $settings['beacon'], __( 'A few hundred bytes of JavaScript that reports the referral from the address bar. Without it, a cached landing page records nothing.', 'affiliatant-for-woocommerce' ) ); self::checkbox( 'pixel', __( 'Receipt pixel', 'affiliatant-for-woocommerce' ), (int) $settings['pixel'], __( 'A signed one-pixel image on the order received page, carrying the same event id as the server report so the two deduplicate. Needs the key prefix and the signing secret.', 'affiliatant-for-woocommerce' ) ); self::checkbox( 'report_all', __( 'Report every order', 'affiliatant-for-woocommerce' ), (int) $settings['report_all'], __( 'On, Affiliatant sees the whole order flow and can compute a real conversion rate. Off, only orders with a referral or a coupon are sent.', 'affiliatant-for-woocommerce' ) ); // Paid statuses. echo ''; echo ''; submit_button( __( 'Save settings', 'affiliatant-for-woocommerce' ) ); echo '
'; // ---- Manual attribution. echo '

' . esc_html__( 'Attribute an order by hand', 'affiliatant-for-woocommerce' ) . '

'; echo '

' . esc_html__( 'For an order taken over the telephone or created in the admin, where there was never a browser carrying a referral.', 'affiliatant-for-woocommerce' ) . '

'; echo '
'; // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- escaped above. wp_nonce_field( 'affiliatant_set_click' ); echo ''; echo ' '; echo ' '; echo ''; echo '
'; // ---- Recent events. echo '

' . esc_html__( 'Recent events', 'affiliatant-for-woocommerce' ) . '

'; echo '' . '' . '' . '' . '' . '' . '' . '' . ''; if ( array() === $events ) { echo ''; } foreach ( $events as $row ) { echo '' . '' . '' . '' . '' . '' . '' . '' . ''; } echo '
' . esc_html__( 'When', 'affiliatant-for-woocommerce' ) . '' . esc_html__( 'Action', 'affiliatant-for-woocommerce' ) . '' . esc_html__( 'Order', 'affiliatant-for-woocommerce' ) . '' . esc_html__( 'Status', 'affiliatant-for-woocommerce' ) . '' . esc_html__( 'Attempts', 'affiliatant-for-woocommerce' ) . '' . esc_html__( 'Reversed', 'affiliatant-for-woocommerce' ) . '' . esc_html__( 'Error', 'affiliatant-for-woocommerce' ) . '
' . esc_html__( 'Nothing yet.', 'affiliatant-for-woocommerce' ) . '
' . esc_html( self::when( (int) $row['updated'] ) ) . '' . esc_html( (string) $row['action'] ) . '' . esc_html( (string) $row['order_id'] ) . '' . esc_html( (string) $row['status'] ) . '' . esc_html( (string) $row['attempts'] ) . '' . esc_html( (int) $row['amount_minor'] > 0 ? (string) (int) $row['amount_minor'] . ' minor' : '-' ) . '' . esc_html( (string) $row['last_error'] ) . '
'; echo '

' . esc_html__( 'Installing', 'affiliatant-for-woocommerce' ) . '

'; echo '
    '; echo '
  1. ' . esc_html__( 'Enter the secret API key, the signing secret and the programme id above, and save.', 'affiliatant-for-woocommerce' ) . '
  2. '; echo '
  3. ' . esc_html__( 'Press Send a test conversion. It should name an AFFTEST- reference, and that reference should appear in the Affiliatant console flagged as test data.', 'affiliatant-for-woocommerce' ) . '
  4. '; echo '
  5. ' . esc_html__( 'Visit any page of the shop with ?aftid= and a referral id on it, then place a test order and check it appears here as sent.', 'affiliatant-for-woocommerce' ) . '
  6. '; echo '
  7. ' . esc_html__( 'If the shop is behind a page cache, check the referral still records with the page served from cache. That is what the beacon is for and it is the one thing worth testing twice.', 'affiliatant-for-woocommerce' ) . '
  8. '; echo '
'; echo '
'; } /** * An informational panel on the order screen. * * Read only. The merchant attributes an order from the Affiliatant page, * for the HPOS reason given on handle_set_click(). * * The order screen id differs between the legacy post table and HPOS, * and wc_get_page_screen_id() does not exist on every version that can * run this plugin. All three are offered, so the panel appears on * whichever the store actually uses and is simply absent otherwise. * * @return void */ public static function meta_box() { $screens = array( 'shop_order', 'woocommerce_page_wc-orders' ); if ( function_exists( 'wc_get_page_screen_id' ) ) { $screens[] = wc_get_page_screen_id( 'shop-order' ); } add_meta_box( 'affiliatant-order', __( 'Affiliatant', 'affiliatant-for-woocommerce' ), array( __CLASS__, 'render_meta_box' ), array_values( array_unique( array_filter( $screens ) ) ), 'side', 'default' ); } /** * Render the order panel. * * @param WP_Post|WC_Order $post_or_order Whatever the screen handed us. * @return void */ public static function render_meta_box( $post_or_order ) { $order = is_a( $post_or_order, 'WC_Order' ) ? $post_or_order : wc_get_order( $post_or_order ); if ( ! is_a( $order, 'WC_Order' ) ) { return; } $context = Affiliatant_Order::context( $order ); echo '

' . esc_html__( 'Referral', 'affiliatant-for-woocommerce' ) . ':
' . ( array() === $context ? esc_html__( 'None recorded.', 'affiliatant-for-woocommerce' ) : '' . esc_html( $context['click_id'] ) . '' ) . '

'; echo '

' . esc_html__( 'Reported', 'affiliatant-for-woocommerce' ) . ':
' . ( Affiliatant_Queue::has_conversion( (int) $order->get_id() ) ? esc_html__( 'Yes', 'affiliatant-for-woocommerce' ) : esc_html__( 'Not yet', 'affiliatant-for-woocommerce' ) ) . '

'; echo '

' . esc_html__( 'Affiliatant settings and status', 'affiliatant-for-woocommerce' ) . '

'; } // ----------------------------------------------------------------- // Small helpers // ----------------------------------------------------------------- /** * Capability and nonce, in that order, before anything changes. * * @param string $nonce Action name. * @return void */ private static function guard( $nonce ) { if ( ! current_user_can( self::CAP ) ) { wp_die( esc_html__( 'You do not have permission to do that.', 'affiliatant-for-woocommerce' ) ); } check_admin_referer( $nonce ); } /** * Back to the page with a message. * * @param string $code Message code. * @param string $detail Extra text. * @return void */ private static function back( $code, $detail = '' ) { $url = add_query_arg( array( 'page' => self::SLUG, 'affiliatant_notice' => $code, 'affiliatant_detail' => rawurlencode( substr( $detail, 0, 300 ) ), ), admin_url( 'admin.php' ) ); wp_safe_redirect( $url ); exit; } /** * Print the notice carried back from an action. * * @return void */ private static function notice() { if ( empty( $_GET['affiliatant_notice'] ) ) { // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- printing a message, changing nothing. return; } $code = sanitize_key( wp_unslash( $_GET['affiliatant_notice'] ) ); // phpcs:ignore WordPress.Security.NonceVerification.Recommended $detail = isset( $_GET['affiliatant_detail'] ) ? sanitize_text_field( wp_unslash( $_GET['affiliatant_detail'] ) ) : ''; // phpcs:ignore WordPress.Security.NonceVerification.Recommended $messages = array( 'saved' => array( 'success', __( 'Settings saved.', 'affiliatant-for-woocommerce' ) ), 'test_ok' => array( 'success', __( 'Connection test succeeded.', 'affiliatant-for-woocommerce' ) ), 'test_failed' => array( 'error', __( 'Connection test failed.', 'affiliatant-for-woocommerce' ) ), 'retried' => array( 'success', __( 'Queue drained.', 'affiliatant-for-woocommerce' ) ), 'cleared' => array( 'success', __( 'Failed events cleared.', 'affiliatant-for-woocommerce' ) ), 'manual_ok' => array( 'success', __( 'Order attributed.', 'affiliatant-for-woocommerce' ) ), 'manual_failed' => array( 'error', __( 'Could not attribute that order.', 'affiliatant-for-woocommerce' ) ), ); if ( ! isset( $messages[ $code ] ) ) { return; } echo '

' . esc_html( $messages[ $code ][1] ) . ( '' === $detail ? '' : ' ' . esc_html( $detail ) ) . '

'; } /** * A one-button form, so every action is a POST with its own nonce. * * @param string $action Form action URL, already escaped. * @param string $name admin_post action name. * @param string $label Button text. * @param string $class CSS classes. * @return void */ private static function button( $action, $name, $label, $class ) { echo '
'; // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- caller escaped it. wp_nonce_field( $name ); echo ''; echo ''; echo '
'; } /** * One row of the status table. * * @param string $label Label. * @param string $value Value. * @return void */ private static function status_row( $label, $value ) { echo '' . esc_html( $label ) . '' . esc_html( $value ) . ''; } /** * A text input row. * * @param string $name Field name. * @param string $label Label. * @param string $value Current value. * @param string $description Help text. * @return void */ private static function text_field( $name, $label, $value, $description ) { echo ''; echo ''; if ( '' !== $description ) { echo '

' . esc_html( $description ) . '

'; } echo ''; } /** * A secret input row. The stored value is NEVER rendered. * * @param string $name Field name. * @param string $label Label. * @param string $stored Stored value, used only to say whether one is set. * @param string $description Help text. * @return void */ private static function password_field( $name, $label, $stored, $description ) { echo ''; echo ''; if ( '' !== $description ) { echo '

' . esc_html( $description ) . '

'; } echo ''; } /** * A checkbox row. * * @param string $name Field name. * @param string $label Label. * @param int $value 1 or 0. * @param string $description Help text. * @return void */ private static function checkbox( $name, $label, $value, $description ) { echo '' . esc_html( $label ) . ''; echo ''; echo ''; } /** * Is HPOS the authoritative store? * * Through WooCommerce's own feature check, not by reading the option, so * it stays right if the option name ever changes. * * @return bool */ private static function hpos_enabled() { if ( ! class_exists( '\Automattic\WooCommerce\Utilities\OrderUtil' ) ) { return false; } return \Automattic\WooCommerce\Utilities\OrderUtil::custom_orders_table_usage_is_enabled(); } /** * A timestamp in the site's own timezone and format. * * @param int $timestamp Unix time. * @return string */ private static function when( $timestamp ) { if ( $timestamp <= 0 ) { return '-'; } return wp_date( get_option( 'date_format' ) . ' ' . get_option( 'time_format' ), $timestamp ); } /** * One posted field as a string. * * @param array $posted Unslashed POST. * @param string $key Key. * @return string */ private static function field( array $posted, $key ) { return isset( $posted[ $key ] ) && is_scalar( $posted[ $key ] ) ? (string) $posted[ $key ] : ''; } /** * The posted paid statuses, cleaned. * * @param array $posted Unslashed POST. * @return array */ private static function posted_statuses( array $posted ) { if ( ! isset( $posted['paid_statuses'] ) || ! is_array( $posted['paid_statuses'] ) ) { return array(); } $clean = array(); foreach ( $posted['paid_statuses'] as $status ) { $status = sanitize_key( (string) $status ); if ( '' !== $status ) { $clean[] = $status; } } return array_values( array_unique( $clean ) ); } }