$event */ public static function upsert_from_event( array $event ): int { CheckoutMonitoringSchema::ensure_installed(); $table = self::table_name(); $trace_id = sanitize_key( (string) ( $event['traceId'] ?? '' ) ); if ( '' === $trace_id || ! self::table_exists() ) { return 0; } $event_at = self::normalize_mysql_datetime( (string) ( $event['createdAt'] ?? '' ) ); return self::persist_event_data( $table, $trace_id, $event, $event_at, self::read_by_trace_id( $table, $trace_id ) ); } /** * @param array $event * @param array|null $existing */ private static function persist_event_data( string $table, string $trace_id, array $event, string $event_at, ?array $existing, bool $allow_race_recovery = true ): int { global $wpdb; $current_status = is_array( $existing ) ? (string) ( $existing['result_status'] ?? 'started' ) : 'started'; $next_status = self::resolve_result_status( $event, $current_status ); $gateway_id = self::pick_string( (string) ( $event['gatewayId'] ?? '' ), is_array( $existing ) ? (string) ( $existing['gateway_id'] ?? '' ) : '' ); $provider_key = self::pick_string( (string) ( $event['providerKey'] ?? '' ), is_array( $existing ) ? (string) ( $existing['provider_key'] ?? '' ) : '' ); $message = sanitize_text_field( (string) ( $event['message'] ?? '' ) ); $context = is_array( $event['context'] ?? null ) ? $event['context'] : []; $data = [ 'attempt_id' => $trace_id, 'session_key' => self::current_session_key(), 'cart_hash' => self::current_cart_hash(), 'customer_id' => get_current_user_id(), 'gateway_id' => $gateway_id, 'provider_key' => $provider_key, 'entry_mode' => self::resolve_entry_mode( $context, is_array( $existing ) ? (string) ( $existing['entry_mode'] ?? '' ) : '' ), 'flow_mode' => self::resolve_flow_mode( $context, is_array( $existing ) ? (string) ( $existing['flow_mode'] ?? '' ) : '' ), 'result_status' => $next_status, 'failure_stage' => self::resolve_failure_stage( $event, $next_status, is_array( $existing ) ? (string) ( $existing['failure_stage'] ?? '' ) : '' ), 'failure_reason_code' => self::resolve_failure_reason_code( $event, $next_status, is_array( $existing ) ? (string) ( $existing['failure_reason_code'] ?? '' ) : '' ), 'failure_reason_message' => self::resolve_failure_reason_message( $message, $next_status, is_array( $existing ) ? (string) ( $existing['failure_reason_message'] ?? '' ) : '' ), 'order_id' => self::resolve_order_id( $context, is_array( $existing ) ? (int) ( $existing['order_id'] ?? 0 ) : 0 ), 'order_key' => self::pick_string( self::sanitize_short_text( (string) self::read_any_key( $context, [ 'orderKey', 'orderkey', 'order_key' ], '' ), 100 ), is_array( $existing ) ? (string) ( $existing['order_key'] ?? '' ) : '' ), 'currency' => self::pick_string( self::sanitize_short_text( (string) ( $context['currency'] ?? self::current_currency() ), 10 ), is_array( $existing ) ? (string) ( $existing['currency'] ?? '' ) : '' ), 'cart_total' => self::resolve_cart_total( $context, is_array( $existing ) ? $existing['cart_total'] ?? null : null ), 'last_event_at' => $event_at, 'finished_at' => self::resolve_finished_at( $next_status, $event_at, is_array( $existing ) ? (string) ( $existing['finished_at'] ?? '' ) : '' ), 'duration_ms' => 0, 'meta_json' => wp_json_encode( self::build_meta_payload( $context, $event, is_array( $existing ) ? (string) ( $existing['meta_json'] ?? '' ) : '' ) ), ]; if ( is_array( $existing ) ) { $started_at = (string) ( $existing['started_at'] ?? $event_at ); $data['started_at'] = $started_at; $data['duration_ms'] = self::calculate_duration_ms( $started_at, $event_at ); $updated = $wpdb->update( $table, $data, [ 'id' => (int) $existing['id'] ], self::formats_for_update( $data ), [ '%d' ] ); if ( false === $updated ) { return (int) $existing['id']; } return (int) $existing['id']; } $data['trace_id'] = $trace_id; $data['started_at'] = $event_at; $data['duration_ms'] = 0; $inserted = $wpdb->insert( $table, $data, self::formats_for_insert( $data ) ); if ( false !== $inserted && (int) $wpdb->insert_id > 0 ) { return (int) $wpdb->insert_id; } if ( $allow_race_recovery ) { $existing_after_insert = self::read_by_trace_id( $table, $trace_id ); if ( is_array( $existing_after_insert ) ) { return self::persist_event_data( $table, $trace_id, $event, $event_at, $existing_after_insert, false ); } } return 0; } /** * @return array|null */ private static function read_by_trace_id( string $table, string $trace_id ): ?array { global $wpdb; $row = $wpdb->get_row( $wpdb->prepare( 'SELECT * FROM %i WHERE trace_id = %s LIMIT 1', $table, $trace_id ), ARRAY_A ); return is_array( $row ) ? $row : null; } public static function clear(): void { global $wpdb; CheckoutMonitoringSchema::ensure_installed(); if ( self::table_exists() ) { $wpdb->query( $wpdb->prepare( 'DELETE FROM %i', self::table_name() ) ); } } public static function prune_to_max_runs( int $max_runs ): void { global $wpdb; CheckoutMonitoringSchema::ensure_installed(); if ( ! self::table_exists() ) { return; } $max_runs = max( 20, min( 500, $max_runs ) ); $table = self::table_name(); $wpdb->query( $wpdb->prepare( 'DELETE FROM %i WHERE id NOT IN (SELECT id FROM (SELECT id FROM %i ORDER BY last_event_at DESC, id DESC LIMIT %d) AS ips_flow_keep)', $table, $table, $max_runs ) ); } /** * @return array */ public static function verified_gateway_ids( int $limit = 100 ): array { return array_keys( self::last_verified_gateways( $limit ) ); } /** * @return array */ public static function last_verified_gateways( int $limit = 100 ): array { global $wpdb; CheckoutMonitoringSchema::ensure_installed(); if ( ! self::table_exists() ) { return []; } $table = self::table_name(); $limit = max( 1, min( 500, $limit ) ); $rows = $wpdb->get_results( $wpdb->prepare( 'SELECT trace_id, gateway_id, order_id, finished_at, last_event_at, started_at, meta_json FROM %i WHERE result_status = %s ORDER BY COALESCE(finished_at, last_event_at, started_at) DESC LIMIT %d', $table, 'success', $limit ), ARRAY_A ); if ( ! is_array( $rows ) ) { return []; } $verified = []; foreach ( $rows as $row ) { $meta = json_decode( (string) ( $row['meta_json'] ?? '' ), true ); $verified_from_meta = is_array( $meta ) ? self::normalize_gateway_id_list( self::read_any_key( $meta, [ 'verifiedGatewayIds', 'verifiedgatewayids', 'verified_gateway_ids' ], [] ) ) : []; if ( ! empty( $verified_from_meta ) ) { foreach ( $verified_from_meta as $verified_gateway_id ) { if ( ! isset( $verified[ $verified_gateway_id ] ) ) { $verified[ $verified_gateway_id ] = self::build_last_verified_payload( $verified_gateway_id, $row ); } } continue; } $gateway_id = sanitize_key( (string) ( $row['gateway_id'] ?? '' ) ); if ( '' !== $gateway_id && ! isset( $verified[ $gateway_id ] ) ) { $verified[ $gateway_id ] = self::build_last_verified_payload( $gateway_id, $row ); } } return $verified; } /** * @return array */ public static function last_verified_integrations( int $limit = 100 ): array { return self::last_verified_from_meta_list( [ 'verifiedIntegrationIds', 'verifiedintegrationids', 'verified_integration_ids' ], $limit, 'integration' ); } /** * @return array */ public static function last_verified_wallets( int $limit = 100 ): array { return self::last_verified_from_meta_list( [ 'verifiedWalletIds', 'verifiedwalletids', 'verified_wallet_ids' ], $limit, 'wallet' ); } public static function table_name(): string { return ( new Checkout_FlowRuns() )->get_name(); } public static function table_exists(): bool { global $wpdb; $table = self::table_name(); return $wpdb->get_var( $wpdb->prepare( 'SHOW TABLES LIKE %s', $table ) ) === $table; } /** * @param array $context * @param array $event * @return array */ private static function build_meta_payload( array $context, array $event, string $existing_meta_json = '' ): array { $existing_meta = '' !== $existing_meta_json ? json_decode( $existing_meta_json, true ) : []; $meta = is_array( $existing_meta ) ? $existing_meta : []; foreach ( [ 'amazonReturn', 'sessionActive', 'storeApiAvailable', 'paymentMethodCount', 'flowMode', 'entryMode', 'redirectUrl', 'redirectTarget', 'redirectPath', 'redirectHost', 'redirectProtocol', 'redirectIsEmpty', 'redirectIsHashOnly', 'redirectSameOrigin', 'redirectParseFailed', 'currentHref', 'paymentMethod', 'paymentMethodTitle', 'orderStatus', 'thankyouSource', 'expressPaymentType', 'expresspaymenttype', 'express_payment_type', 'cleanup', 'cart', 'session', 'order' ] as $key ) { if ( array_key_exists( $key, $context ) ) { $meta[ $key ] = $context[ $key ]; } } $verified_gateway_ids = array_merge( self::normalize_gateway_id_list( self::read_any_key( $meta, [ 'verifiedGatewayIds', 'verifiedgatewayids', 'verified_gateway_ids' ], [] ) ), self::normalize_gateway_id_list( self::read_any_key( $context, [ 'verifiedGatewayIds', 'verifiedgatewayids', 'verified_gateway_ids' ], [] ) ) ); $verified_gateway_ids = self::normalize_gateway_id_list( $verified_gateway_ids ); if ( ! empty( $verified_gateway_ids ) ) { $meta['verifiedGatewayIds'] = $verified_gateway_ids; } $verified_integration_ids = array_merge( self::normalize_gateway_id_list( self::read_any_key( $meta, [ 'verifiedIntegrationIds', 'verifiedintegrationids', 'verified_integration_ids' ], [] ) ), self::normalize_gateway_id_list( self::read_any_key( $context, [ 'verifiedIntegrationIds', 'verifiedintegrationids', 'verified_integration_ids' ], [] ) ) ); $verified_integration_ids = self::normalize_gateway_id_list( $verified_integration_ids ); if ( ! empty( $verified_integration_ids ) ) { $meta['verifiedIntegrationIds'] = $verified_integration_ids; } $verified_wallet_ids = array_merge( self::normalize_gateway_id_list( self::read_any_key( $meta, [ 'verifiedWalletIds', 'verifiedwalletids', 'verified_wallet_ids' ], [] ) ), self::normalize_gateway_id_list( self::read_any_key( $context, [ 'verifiedWalletIds', 'verifiedwalletids', 'verified_wallet_ids' ], [] ) ) ); $verified_wallet_ids = self::normalize_gateway_id_list( $verified_wallet_ids ); if ( ! empty( $verified_wallet_ids ) ) { $meta['verifiedWalletIds'] = $verified_wallet_ids; } $meta['lastEvent'] = sanitize_key( (string) ( $event['event'] ?? '' ) ); $meta['lastStep'] = sanitize_key( (string) ( $event['step'] ?? '' ) ); return $meta; } /** * @param mixed $value * @return array */ private static function normalize_gateway_id_list( $value ): array { if ( is_string( $value ) ) { $decoded = json_decode( $value, true ); $value = is_array( $decoded ) ? $decoded : preg_split( '/[,\\s]+/', $value ); } if ( ! is_array( $value ) ) { return []; } $ids = []; foreach ( $value as $gateway_id ) { $gateway_id = sanitize_key( (string) $gateway_id ); if ( '' !== $gateway_id ) { $ids[ $gateway_id ] = true; } } return array_keys( $ids ); } /** * @param array $context */ private static function resolve_entry_mode( array $context, string $existing ): string { $candidate = sanitize_key( (string) ( $context['entryMode'] ?? '' ) ); if ( '' === $candidate && ! empty( $context['amazonReturn'] ) ) { $candidate = 'return'; } return self::pick_string( $candidate, $existing ?: 'standard' ); } /** * @param array $context */ private static function resolve_flow_mode( array $context, string $existing ): string { $candidate = sanitize_key( (string) ( $context['flowMode'] ?? '' ) ); return self::pick_string( $candidate, $existing ); } /** * @param array $event */ private static function resolve_result_status( array $event, string $current_status ): string { $status = sanitize_key( (string) ( $event['status'] ?? '' ) ); $event_name = sanitize_key( (string) ( $event['event'] ?? '' ) ); if ( in_array( $event_name, [ 'order_received', 'thank_you_loaded', 'checkout_completed' ], true ) ) { return 'success'; } if ( 'success' === $current_status ) { return 'success'; } if ( in_array( $status, [ 'error', 'failed', 'critical' ], true ) ) { return 'failed'; } if ( in_array( $event_name, [ 'gateway_redirect_blocked', 'gateway_redirect_stalled' ], true ) ) { return 'failed'; } if ( in_array( $event_name, [ 'gateway_redirect', 'checkout_submit_success' ], true ) ) { return 'redirected'; } if ( in_array( $current_status, [ 'success', 'failed', 'redirected' ], true ) ) { return $current_status; } return 'in_progress'; } /** * @param array $event */ private static function resolve_failure_stage( array $event, string $result_status, string $existing ): string { if ( 'failed' !== $result_status ) { return ''; } $step = sanitize_key( (string) ( $event['step'] ?? '' ) ); return self::pick_string( $step, $existing ); } private static function resolve_failure_reason_code( array $event, string $result_status, string $existing ): string { if ( 'failed' !== $result_status ) { return ''; } $context = is_array( $event['context'] ?? null ) ? $event['context'] : []; $candidate = sanitize_key( (string) ( $context['code'] ?? '' ) ); if ( '' === $candidate ) { $candidate = sanitize_key( (string) ( $event['event'] ?? '' ) ); } return self::pick_string( $candidate, $existing ); } private static function resolve_failure_reason_message( string $message, string $result_status, string $existing ): string { if ( 'failed' !== $result_status ) { return ''; } return self::pick_string( self::sanitize_short_text( $message, 255 ), $existing ); } /** * @param array $context * @param mixed $existing * @return float|null */ private static function resolve_cart_total( array $context, $existing ) { $cart_total = self::read_any_key( $context, [ 'cartTotal', 'carttotal', 'cart_total' ], null ); if ( is_numeric( $cart_total ) ) { return (float) $cart_total; } $current_total = self::current_cart_total(); if ( null !== $current_total ) { return $current_total; } return null !== $existing && '' !== (string) $existing ? (float) $existing : null; } /** * @param array $context */ private static function resolve_order_id( array $context, int $existing ): int { $order_id = self::normalize_order_id( self::read_any_key( $context, [ 'orderId', 'orderid', 'order_id' ], null ) ); if ( $order_id > 0 ) { return $order_id; } $session = is_array( $context['session'] ?? null ) ? $context['session'] : []; if ( ! empty( $session ) ) { $order_id = self::normalize_order_id( self::read_any_key( $session, [ 'orderId', 'orderid', 'order_id' ], null ) ); if ( $order_id > 0 ) { return $order_id; } $order_id = self::first_order_id_from_list( self::read_any_key( $session, [ 'orderIds', 'orderids', 'order_ids' ], [] ) ); if ( $order_id > 0 ) { return $order_id; } } return $existing; } /** * @param mixed $value */ private static function normalize_order_id( $value ): int { if ( is_numeric( $value ) ) { return absint( (int) $value ); } return 0; } /** * @param mixed $value */ private static function first_order_id_from_list( $value ): int { if ( is_string( $value ) ) { $decoded = json_decode( $value, true ); $value = is_array( $decoded ) ? $decoded : preg_split( '/[,\\s]+/', $value ); } if ( ! is_array( $value ) ) { return 0; } foreach ( $value as $order_id ) { $order_id = self::normalize_order_id( $order_id ); if ( $order_id > 0 ) { return $order_id; } } return 0; } /** * @param array $meta_keys * @return array */ private static function last_verified_from_meta_list( array $meta_keys, int $limit = 100, string $inference_type = '' ): array { global $wpdb; CheckoutMonitoringSchema::ensure_installed(); if ( ! self::table_exists() ) { return []; } $table = self::table_name(); $limit = max( 1, min( 500, $limit ) ); $rows = $wpdb->get_results( $wpdb->prepare( 'SELECT trace_id, gateway_id, provider_key, order_id, finished_at, last_event_at, started_at, meta_json FROM %i WHERE result_status = %s ORDER BY COALESCE(finished_at, last_event_at, started_at) DESC LIMIT %d', $table, 'success', $limit ), ARRAY_A ); if ( ! is_array( $rows ) ) { return []; } $verified = []; foreach ( $rows as $row ) { $meta = json_decode( (string) ( $row['meta_json'] ?? '' ), true ); $verified_from_meta = is_array( $meta ) ? self::normalize_gateway_id_list( self::read_any_key( $meta, $meta_keys, [] ) ) : []; if ( empty( $verified_from_meta ) ) { $verified_from_meta = 'integration' === $inference_type ? self::infer_verified_integration_ids( $row, is_array( $meta ) ? $meta : [] ) : ( 'wallet' === $inference_type ? self::infer_verified_wallet_ids( $row, is_array( $meta ) ? $meta : [] ) : [] ); } foreach ( $verified_from_meta as $verified_id ) { if ( ! isset( $verified[ $verified_id ] ) ) { $verified[ $verified_id ] = self::build_last_verified_payload( $verified_id, $row ); } } } return $verified; } /** * @param array $row * @param array $meta * @return array */ private static function infer_verified_integration_ids( array $row, array $meta ): array { $provider_key = sanitize_key( (string) ( $row['provider_key'] ?? '' ) ); $gateway_id = sanitize_key( (string) ( $row['gateway_id'] ?? '' ) ); $express_payment_type = self::read_express_payment_type( $meta ); if ( 'stripe-express' === $provider_key || ( 'stripe' === $gateway_id && in_array( $express_payment_type, [ 'amazon_pay', 'apple_pay', 'google_pay', 'link' ], true ) ) ) { return [ 'stripe_express_wallets' ]; } if ( 'woopayments-wallet' === $provider_key || ( 'woocommerce_payments' === $gateway_id && '' !== $express_payment_type ) ) { return [ 'woopayments_express_wallets' ]; } if ( 'amazon-pay' === $provider_key || in_array( $gateway_id, [ 'amazon_payments_advanced', 'amazon_payments_advanced_express' ], true ) ) { return [ 'amazon_pay_express' ]; } return []; } /** * @param array $row * @param array $meta * @return array */ private static function infer_verified_wallet_ids( array $row, array $meta ): array { $provider_key = sanitize_key( (string) ( $row['provider_key'] ?? '' ) ); $gateway_id = sanitize_key( (string) ( $row['gateway_id'] ?? '' ) ); $express_payment_type = self::read_express_payment_type( $meta ); if ( '' === $express_payment_type ) { return []; } if ( 'stripe-express' === $provider_key || 'stripe' === $gateway_id ) { $stripe_wallet_id = self::stripe_express_wallet_id( $express_payment_type ); return '' !== $stripe_wallet_id ? [ $stripe_wallet_id ] : []; } if ( 'woopayments-wallet' === $provider_key || 'woocommerce_payments' === $gateway_id ) { return in_array( $express_payment_type, [ 'apple_pay', 'google_pay', 'amazon_pay' ], true ) ? [ 'woocommerce_payments_' . $express_payment_type ] : []; } if ( 'amazon-pay' === $provider_key || in_array( $gateway_id, [ 'amazon_payments_advanced', 'amazon_payments_advanced_express' ], true ) ) { return [ 'amazon_pay' ]; } return []; } /** * @param array $meta */ private static function read_express_payment_type( array $meta ): string { return sanitize_key( (string) self::read_any_key( $meta, [ 'expressPaymentType', 'expresspaymenttype', 'express_payment_type' ], '' ) ); } private static function stripe_express_wallet_id( string $express_payment_type ): string { switch ( $express_payment_type ) { case 'amazon_pay': return 'stripe_amazon_pay'; case 'apple_pay': return 'stripe_apple_pay'; case 'google_pay': return 'stripe_google_pay'; case 'link': return 'stripe_link'; default: return ''; } } /** * @param array $row * @return array{gatewayId:string,lastVerifiedAt:string,lastVerifiedTraceId:string,lastVerifiedOrderId:int} */ private static function build_last_verified_payload( string $gateway_id, array $row ): array { $last_verified_at = self::pick_string( (string) ( $row['finished_at'] ?? '' ), self::pick_string( (string) ( $row['last_event_at'] ?? '' ), (string) ( $row['started_at'] ?? '' ) ) ); return [ 'gatewayId' => $gateway_id, 'lastVerifiedAt' => self::mysql_to_iso( $last_verified_at ), 'lastVerifiedTraceId' => sanitize_key( (string) ( $row['trace_id'] ?? '' ) ), 'lastVerifiedOrderId' => absint( (int) ( $row['order_id'] ?? 0 ) ), ]; } private static function resolve_finished_at( string $result_status, string $event_at, string $existing ): ?string { if ( in_array( $result_status, [ 'success', 'failed' ], true ) ) { return $event_at; } return '' !== $existing ? $existing : null; } private static function calculate_duration_ms( string $started_at, string $last_event_at ): int { $started = strtotime( $started_at . ' UTC' ); $last = strtotime( $last_event_at . ' UTC' ); if ( false === $started || false === $last || $last < $started ) { return 0; } return (int) ( $last - $started ) * 1000; } private static function current_session_key(): string { if ( function_exists( 'WC' ) && WC()->session && method_exists( WC()->session, 'get_customer_id' ) ) { return self::sanitize_short_text( (string) WC()->session->get_customer_id(), 191 ); } return ''; } private static function current_cart_hash(): string { if ( function_exists( 'WC' ) && WC()->cart && method_exists( WC()->cart, 'get_cart_hash' ) ) { return self::sanitize_short_text( (string) WC()->cart->get_cart_hash(), 191 ); } return ''; } private static function current_currency(): string { return function_exists( 'get_woocommerce_currency' ) ? self::sanitize_short_text( (string) get_woocommerce_currency(), 10 ) : ''; } /** * @return float|null */ private static function current_cart_total() { if ( ! function_exists( 'WC' ) || ! WC()->cart || ! method_exists( WC()->cart, 'get_totals' ) ) { return null; } $totals = WC()->cart->get_totals(); return isset( $totals['total'] ) && is_numeric( $totals['total'] ) ? (float) $totals['total'] : null; } /** * @param array $data * @return array */ private static function formats_for_insert( array $data ): array { $formats = []; foreach ( array_keys( $data ) as $key ) { $formats[] = in_array( $key, [ 'customer_id', 'order_id', 'duration_ms' ], true ) ? '%d' : ( 'cart_total' === $key ? '%f' : '%s' ); } return $formats; } /** * @param array $data * @return array */ private static function formats_for_update( array $data ): array { return self::formats_for_insert( $data ); } private static function normalize_mysql_datetime( string $value ): string { $timestamp = strtotime( $value ); if ( false === $timestamp ) { return current_time( 'mysql', true ); } return gmdate( 'Y-m-d H:i:s', $timestamp ); } private static function mysql_to_iso( string $value ): string { if ( '' === $value ) { return ''; } $timestamp = strtotime( $value . ' UTC' ); if ( false === $timestamp ) { return ''; } return gmdate( 'c', $timestamp ); } /** * @param array $source * @param array $keys * @param mixed $default * @return mixed */ private static function read_any_key( array $source, array $keys, $default = null ) { foreach ( $keys as $key ) { if ( array_key_exists( $key, $source ) ) { return $source[ $key ]; } } return $default; } private static function pick_string( string $candidate, string $fallback ): string { return '' !== $candidate ? $candidate : $fallback; } private static function sanitize_short_text( string $value, int $max_length ): string { $value = sanitize_text_field( $value ); if ( strlen( $value ) > $max_length ) { $value = substr( $value, 0, $max_length ); } return $value; } }