init_hooks(); } /** * Initialize hooks. */ private function init_hooks() { add_action( 'init', array( $this, 'register_post_type' ) ); add_shortcode( 'afridirect_payment_request', array( $this, 'render_shortcode' ) ); add_action( 'wp_ajax_afridirect_start_request', array( $this, 'ajax_start' ) ); add_action( 'wp_ajax_nopriv_afridirect_start_request', array( $this, 'ajax_start' ) ); } /** * Register the (private) post type that stores requests. */ public function register_post_type() { register_post_type( self::POST_TYPE, array( 'labels' => array( 'name' => __( 'Payment Requests', 'afridirect-payment' ), 'singular_name' => __( 'Payment Request', 'afridirect-payment' ), ), 'public' => false, 'show_ui' => false, 'show_in_menu' => false, 'show_in_rest' => false, 'rewrite' => false, 'query_var' => false, 'supports' => array( 'title' ), ) ); } /** * Create a request. * * @param array $data customer_name, customer_email, amount, currency, note, method_ids, timer_minutes, valid_days. * @return int|WP_Error Request ID or error. */ public static function create( $data ) { $name = sanitize_text_field( isset( $data['customer_name'] ) ? $data['customer_name'] : '' ); $email = sanitize_email( isset( $data['customer_email'] ) ? $data['customer_email'] : '' ); $amount = round( (float) ( isset( $data['amount'] ) ? $data['amount'] : 0 ), 2 ); $currency = strtoupper( preg_replace( '/[^A-Za-z]/', '', (string) ( isset( $data['currency'] ) ? $data['currency'] : '' ) ) ); $note = sanitize_textarea_field( isset( $data['note'] ) ? $data['note'] : '' ); $timer = min( 1440, max( 1, absint( isset( $data['timer_minutes'] ) ? $data['timer_minutes'] : 30 ) ) ); $days = min( 365, absint( isset( $data['valid_days'] ) ? $data['valid_days'] : 7 ) ); if ( '' === $name ) { return new WP_Error( 'name', __( 'Please enter the customer\'s name.', 'afridirect-payment' ) ); } if ( '' !== (string) ( isset( $data['customer_email'] ) ? $data['customer_email'] : '' ) && ! is_email( $email ) ) { return new WP_Error( 'email', __( 'That email address does not look right.', 'afridirect-payment' ) ); } if ( $amount <= 0 ) { return new WP_Error( 'amount', __( 'Please enter an amount greater than zero.', 'afridirect-payment' ) ); } if ( strlen( $currency ) < 3 || strlen( $currency ) > 5 ) { return new WP_Error( 'currency', __( 'Please enter a currency code such as USD or NGN.', 'afridirect-payment' ) ); } $library = array_keys( AfriDirect_Payment_Methods::get_all() ); if ( empty( $library ) ) { return new WP_Error( 'no_methods', __( 'Add a payment method first, so the customer knows where to send the money.', 'afridirect-payment' ) ); } $method_ids = isset( $data['method_ids'] ) ? array_map( 'sanitize_key', (array) $data['method_ids'] ) : array(); $method_ids = array_values( array_intersect( $method_ids, $library ) ); if ( empty( $method_ids ) ) { $method_ids = $library; } $reference = 'REQ-' . strtoupper( wp_generate_password( 6, false ) ); $request_id = wp_insert_post( array( 'post_type' => self::POST_TYPE, 'post_status' => 'publish', 'post_title' => $reference, ) ); if ( is_wp_error( $request_id ) || ! $request_id ) { return new WP_Error( 'save', __( 'The request could not be saved. Please try again.', 'afridirect-payment' ) ); } $meta = array( 'customer_name' => $name, 'customer_email' => $email, 'amount' => $amount, 'currency' => $currency, 'note' => $note, 'method_ids' => $method_ids, 'timer_minutes' => $timer, 'valid_until' => $days > 0 ? time() + $days * DAY_IN_SECONDS : 0, 'access_key' => wp_generate_password( 32, false ), 'status' => 'open', 'payment_id' => 0, 'emailed_at' => 0, ); foreach ( $meta as $key => $value ) { update_post_meta( $request_id, '_afridirect_request_' . $key, $value ); } return (int) $request_id; } /** * Get a request. * * @param int $request_id Request ID. * @return array|false */ public static function get( $request_id ) { $post = get_post( $request_id ); if ( ! $post || self::POST_TYPE !== $post->post_type ) { return false; } $id = (int) $post->ID; $methods = get_post_meta( $id, '_afridirect_request_method_ids', true ); return array( 'id' => $id, 'reference' => $post->post_title, 'customer_name' => (string) get_post_meta( $id, '_afridirect_request_customer_name', true ), 'customer_email' => (string) get_post_meta( $id, '_afridirect_request_customer_email', true ), 'amount' => (float) get_post_meta( $id, '_afridirect_request_amount', true ), 'currency' => (string) get_post_meta( $id, '_afridirect_request_currency', true ), 'note' => (string) get_post_meta( $id, '_afridirect_request_note', true ), 'method_ids' => is_array( $methods ) ? $methods : array(), 'timer_minutes' => absint( get_post_meta( $id, '_afridirect_request_timer_minutes', true ) ), 'valid_until' => absint( get_post_meta( $id, '_afridirect_request_valid_until', true ) ), 'access_key' => (string) get_post_meta( $id, '_afridirect_request_access_key', true ), 'status' => (string) get_post_meta( $id, '_afridirect_request_status', true ), 'payment_id' => absint( get_post_meta( $id, '_afridirect_request_payment_id', true ) ), 'emailed_at' => absint( get_post_meta( $id, '_afridirect_request_emailed_at', true ) ), 'created' => (int) get_post_time( 'U', true, $post ), ); } /** * Latest requests, newest first. * * @param int $limit Maximum number of requests. * @return array[] */ public static function get_all( $limit = 100 ) { $ids = get_posts( array( 'post_type' => self::POST_TYPE, 'post_status' => 'publish', 'posts_per_page' => $limit, 'orderby' => 'date', 'order' => 'DESC', 'fields' => 'ids', 'no_found_rows' => true, ) ); $requests = array(); foreach ( $ids as $id ) { $request = self::get( $id ); if ( $request ) { $requests[] = $request; } } return $requests; } /** * The private link a customer opens to pay a request. * * @param array $request Request data. * @return string */ public static function get_link( $request ) { $page_id = (int) get_option( 'afridirect_request_page_id', 0 ); if ( ! $page_id && function_exists( 'afridirect_payment' ) ) { afridirect_payment()->ensure_pages(); $page_id = (int) get_option( 'afridirect_request_page_id', 0 ); } $base = $page_id ? get_permalink( $page_id ) : ''; if ( ! $base ) { $base = home_url( '/' ); } return add_query_arg( array( 'rid' => $request['id'], 'key' => $request['access_key'], ), $base ); } /** * Where a request stands, derived from the payment (if any) made against it. * * Codes: open, in_progress, review, paid, declined, expired, cancelled. * * @param array $request Request data. * @return array code, label, payment (payment data or null). */ public static function get_state( $request ) { $labels = array( 'open' => __( 'Open', 'afridirect-payment' ), 'in_progress' => __( 'In progress', 'afridirect-payment' ), 'review' => __( 'Under review', 'afridirect-payment' ), 'paid' => __( 'Paid', 'afridirect-payment' ), 'declined' => __( 'Declined', 'afridirect-payment' ), 'expired' => __( 'Expired', 'afridirect-payment' ), 'cancelled' => __( 'Cancelled', 'afridirect-payment' ), ); $payment = $request['payment_id'] ? AfriDirect_Payments::get_payment( $request['payment_id'] ) : null; $code = 'open'; if ( 'cancelled' === $request['status'] ) { $code = 'cancelled'; } elseif ( $payment && 'approved' === $payment['status'] ) { $code = 'paid'; } elseif ( $payment && 'needs_review' === $payment['status'] ) { $code = 'review'; } elseif ( $payment && 'pending_proof' === $payment['status'] && $payment['expires_at'] > time() ) { $code = 'in_progress'; } elseif ( $request['valid_until'] && time() > $request['valid_until'] ) { $code = 'expired'; } elseif ( $payment && 'rejected' === $payment['status'] ) { $code = 'declined'; } return array( 'code' => $code, 'label' => $labels[ $code ], 'payment' => $payment, ); } /** * Cancel a request. Its link stops working; any payment already made is untouched. * * @param int $request_id Request ID. */ public static function cancel( $request_id ) { if ( self::get( $request_id ) ) { update_post_meta( $request_id, '_afridirect_request_status', 'cancelled' ); } } /** * Delete a request. Payments made against it are kept. * * @param int $request_id Request ID. */ public static function delete( $request_id ) { if ( self::get( $request_id ) ) { wp_delete_post( $request_id, true ); } } /** * Start a payment for a request: this is the moment the countdown begins. * * @param array $request Request data. * @param string $method_id Chosen payment method ID. * @return int|WP_Error Payment ID or error. */ public static function begin_payment( $request, $method_id ) { $method = AfriDirect_Payment_Methods::get( $method_id ); if ( ! $method || ! in_array( $method_id, $request['method_ids'], true ) ) { return new WP_Error( 'method', __( 'Please choose one of the available payment methods.', 'afridirect-payment' ) ); } $timer = $request['timer_minutes'] > 0 ? $request['timer_minutes'] : 30; $payment_id = AfriDirect_Payments::create_payment( array( 'form_id' => 0, 'amount' => $request['amount'], 'currency' => $request['currency'], 'method_id' => $method_id, 'method_label' => $method['label'], 'payer_name' => $request['customer_name'], 'payer_email' => $request['customer_email'], 'payer_phone' => '', 'timer_minutes' => $timer, // Real (UTC) Unix time: the browser countdown compares it with Date.now(). 'expires_at' => time() + $timer * MINUTE_IN_SECONDS, ) ); if ( is_wp_error( $payment_id ) ) { return $payment_id; } update_post_meta( $payment_id, '_afridirect_request_id', $request['id'] ); if ( '' !== $request['note'] ) { update_post_meta( $payment_id, '_afridirect_note', $request['note'] ); } update_post_meta( $request['id'], '_afridirect_request_payment_id', $payment_id ); /** This action is documented in includes/class-ajax-handler.php. */ do_action( 'afridirect_payment_created', $payment_id ); do_action( 'afridirect_request_payment_started', $request['id'], $payment_id ); return (int) $payment_id; } /** * Resolve a request from the query string and check its private key. * * @param int $request_id Request ID. * @param string $key Access key from the link. * @return array|false */ private static function get_verified( $request_id, $key ) { $request = $request_id ? self::get( $request_id ) : false; if ( ! $request || '' === $request['access_key'] || '' === $key || ! hash_equals( $request['access_key'], $key ) ) { return false; } return $request; } /** * Render the customer's request page. * * @return string */ public function render_shortcode() { // A public, bookmarkable link: there is no nonce. The private key in the link is checked with hash_equals(). // phpcs:disable WordPress.Security.NonceVerification.Recommended $request_id = isset( $_GET['rid'] ) ? absint( wp_unslash( $_GET['rid'] ) ) : 0; $key = isset( $_GET['key'] ) ? sanitize_text_field( wp_unslash( $_GET['key'] ) ) : ''; // phpcs:enable WordPress.Security.NonceVerification.Recommended $request = self::get_verified( $request_id, $key ); if ( ! $request ) { return '

' . esc_html__( 'This payment link is not valid.', 'afridirect-payment' ) . '

'; } $state = self::get_state( $request ); $methods = array(); foreach ( $request['method_ids'] as $method_id ) { $method = AfriDirect_Payment_Methods::get( $method_id ); if ( $method ) { $methods[ $method_id ] = $method; } } wp_enqueue_style( 'afridirect-frontend' ); wp_enqueue_script( 'afridirect-request', AFRIDIRECT_PAYMENT_PLUGIN_URL . 'public/js/request.js', array(), afridirect_payment_asset_ver( 'public/js/request.js' ), true ); wp_localize_script( 'afridirect-request', 'afridirectRequest', array( 'ajaxUrl' => admin_url( 'admin-ajax.php' ), 'nonce' => wp_create_nonce( 'afridirect_start_request' ), 'rid' => $request['id'], 'key' => $request['access_key'], 'i18n' => array( 'choose' => __( 'Please choose how you would like to pay.', 'afridirect-payment' ), 'working' => __( 'Starting your payment...', 'afridirect-payment' ), 'error' => __( 'Something went wrong. Please try again.', 'afridirect-payment' ), ), ) ); ob_start(); include AFRIDIRECT_PAYMENT_PLUGIN_DIR . 'public/views/request.php'; return ob_get_clean(); } /** * AJAX: the customer clicked "Pay now". */ public function ajax_start() { check_ajax_referer( 'afridirect_start_request', 'nonce' ); $request_id = isset( $_POST['rid'] ) ? absint( wp_unslash( $_POST['rid'] ) ) : 0; $key = isset( $_POST['key'] ) ? sanitize_text_field( wp_unslash( $_POST['key'] ) ) : ''; $method_id = isset( $_POST['method_id'] ) ? sanitize_key( wp_unslash( $_POST['method_id'] ) ) : ''; $request = self::get_verified( $request_id, $key ); if ( ! $request ) { wp_send_json_error( array( 'message' => __( 'This payment link is not valid.', 'afridirect-payment' ) ) ); } $state = self::get_state( $request ); // Already under way: take the customer to the payment they started instead of creating another. if ( in_array( $state['code'], array( 'in_progress', 'review' ), true ) && $state['payment'] ) { wp_send_json_success( array( 'redirect_url' => AfriDirect_Payments::get_status_url( $state['payment'] ) ) ); } if ( ! in_array( $state['code'], array( 'open', 'declined' ), true ) ) { wp_send_json_error( array( 'message' => __( 'This payment request can no longer be paid.', 'afridirect-payment' ) ) ); } $payment_id = self::begin_payment( $request, $method_id ); if ( is_wp_error( $payment_id ) ) { wp_send_json_error( array( 'message' => $payment_id->get_error_message() ) ); } $payment = AfriDirect_Payments::get_payment( $payment_id ); wp_send_json_success( array( 'redirect_url' => AfriDirect_Payments::get_status_url( $payment ) ) ); } }