'USD', 'stripe_enabled' => 0, 'stripe_publishable' => '', 'stripe_secret' => '', 'paypal_enabled' => 0, 'paypal_email' => '', 'paypal_sandbox' => 0, 'paystack_enabled' => 0, 'paystack_secret' => '', 'flutterwave_enabled' => 0, 'flutterwave_secret' => '', 'razorpay_enabled' => 0, 'razorpay_key_id' => '', 'razorpay_secret' => '', 'mollie_enabled' => 0, 'mollie_key' => '', 'square_enabled' => 0, 'square_token' => '', 'square_location' => '', 'coinbase_enabled' => 0, 'coinbase_key' => '', 'nowpayments_enabled' => 0, 'nowpayments_key' => '', 'cryptowallet_enabled' => 0, 'cryptowallet_addresses' => '', 'cryptowallet_note' => __( 'Send the exact total to one of the wallet addresses below, then reply to your confirmation email with the transaction hash.', 'phemightforms' ), 'offline_enabled' => 1, 'offline_instructions' => __( 'We will contact you with payment instructions to complete your order.', 'phemightforms' ), 'coupons' => [], ]; return wp_parse_args( get_option( self::OPTION, [] ), $defaults ); } /** * Every supported payment provider, regardless of configuration. * * @return array slug => label */ public static function providers() { return [ 'stripe' => __( 'Stripe (credit & debit cards)', 'phemightforms' ), 'paypal' => __( 'PayPal', 'phemightforms' ), 'paystack' => __( 'Paystack', 'phemightforms' ), 'flutterwave' => __( 'Flutterwave', 'phemightforms' ), 'razorpay' => __( 'Razorpay', 'phemightforms' ), 'mollie' => __( 'Mollie', 'phemightforms' ), 'square' => __( 'Square', 'phemightforms' ), 'coinbase' => __( 'Coinbase Commerce (crypto)', 'phemightforms' ), 'nowpayments' => __( 'NOWPayments (300+ cryptos)', 'phemightforms' ), 'cryptowallet' => __( 'Crypto wallet (direct transfer)', 'phemightforms' ), 'offline' => __( 'Offline / Manual', 'phemightforms' ), ]; } /** * Supported currencies with symbols. * * @return array */ public static function currencies() { return [ 'USD' => '$', 'EUR' => '€', 'GBP' => '£', 'NGN' => '₦', 'CAD' => 'CA$', 'AUD' => 'A$', 'INR' => '₹', 'JPY' => '¥', 'BRL' => 'R$', 'ZAR' => 'R', 'KES' => 'KSh', 'GHS' => 'GH₵', ]; } /** * Active currency code. * * @return string */ public static function currency() { $settings = self::settings(); return isset( self::currencies()[ $settings['currency'] ] ) ? $settings['currency'] : 'USD'; } /** * Symbol for the active currency. * * @return string */ public static function symbol() { $currencies = self::currencies(); return $currencies[ self::currency() ]; } /** * Format an amount with the currency symbol. * * @param float $amount Amount. * @return string */ public static function format( $amount ) { return self::symbol() . number_format( (float) $amount, 2 ); } /** * Providers that are enabled and usable in the builder. * * @return array slug => label */ public static function enabled_providers() { $settings = self::settings(); $labels = self::providers(); $ready = [ 'stripe' => $settings['stripe_enabled'] && $settings['stripe_secret'], 'paypal' => $settings['paypal_enabled'] && $settings['paypal_email'], 'paystack' => $settings['paystack_enabled'] && $settings['paystack_secret'], 'flutterwave' => $settings['flutterwave_enabled'] && $settings['flutterwave_secret'], 'razorpay' => $settings['razorpay_enabled'] && $settings['razorpay_key_id'] && $settings['razorpay_secret'], 'mollie' => $settings['mollie_enabled'] && $settings['mollie_key'], 'square' => $settings['square_enabled'] && $settings['square_token'] && $settings['square_location'], 'coinbase' => $settings['coinbase_enabled'] && $settings['coinbase_key'], 'nowpayments' => $settings['nowpayments_enabled'] && $settings['nowpayments_key'], 'cryptowallet' => $settings['cryptowallet_enabled'] && trim( (string) $settings['cryptowallet_addresses'] ), 'offline' => (bool) $settings['offline_enabled'], ]; $providers = []; foreach ( $ready as $slug => $ok ) { if ( $ok ) { $providers[ $slug ] = $labels[ $slug ]; } } return $providers; } /** * Brand chip used on invoice pay pages. * * @param string $slug Provider slug. * @return array{abbr:string,class:string} */ public static function provider_brand( $slug ) { $map = [ 'stripe' => [ 'abbr' => 'S', 'class' => 'phf-logo-stripe' ], 'paypal' => [ 'abbr' => 'P', 'class' => 'phf-logo-paypal' ], 'paystack' => [ 'abbr' => 'PS', 'class' => 'phf-logo-paystack' ], 'flutterwave' => [ 'abbr' => 'F', 'class' => 'phf-logo-flutterwave' ], 'razorpay' => [ 'abbr' => 'R', 'class' => 'phf-logo-razorpay' ], 'mollie' => [ 'abbr' => 'M', 'class' => 'phf-logo-mollie' ], 'square' => [ 'abbr' => '▢', 'class' => 'phf-logo-square' ], 'coinbase' => [ 'abbr' => 'C', 'class' => 'phf-logo-coinbase' ], 'nowpayments' => [ 'abbr' => 'N', 'class' => 'phf-logo-nowpayments' ], 'cryptowallet' => [ 'abbr' => '₿', 'class' => 'phf-logo-cryptowallet' ], 'offline' => [ 'abbr' => '$', 'class' => 'phf-logo-offline' ], ]; return $map[ $slug ] ?? [ 'abbr' => strtoupper( substr( $slug, 0, 1 ) ), 'class' => 'phf-logo-offline' ]; } /** * Parse a "Label|price" choice string. * * @param string $choice Raw choice. * @return array{0:string,1:float} Label and price. */ public static function parse_choice( $choice ) { $parts = array_pad( explode( '|', (string) $choice, 2 ), 2, '' ); return [ trim( $parts[0] ), self::to_amount( $parts[1] ) ]; } /** * Normalise a raw price string to a float. * * @param mixed $raw Raw value. * @return float */ public static function to_amount( $raw ) { $clean = preg_replace( '/[^0-9.\-]/', '', (string) $raw ); return round( max( 0, (float) $clean ), 2 ); } /** * Whether the form contains at least one priced payment field. * * @param Form $form Form. * @return bool */ public static function form_has_payments( Form $form ) { foreach ( $form->get_fields() as $field ) { if ( isset( $field['type'] ) && 0 === strpos( $field['type'], 'payment_' ) ) { return true; } } return false; } /** * Compute the order total for a submission, server-side. * * @param Form $form Form. * @param array $raw Unslashed $_POST values. * @return array{total:float,items:array,coupon:array|null,discount:float} */ public static function compute_total( Form $form, array $raw ) { $items = []; $total = 0.0; $quantity = 1; foreach ( $form->get_fields() as $field ) { $type = isset( $field['type'] ) ? $field['type'] : ''; $fid = isset( $field['id'] ) ? (int) $field['id'] : 0; $key = 'field_' . $fid; if ( 'payment_quantity' === $type ) { $qty = isset( $raw[ $key ] ) ? absint( $raw[ $key ] ) : 1; $quantity = max( 1, min( 999, $qty ) ); } } foreach ( $form->get_fields() as $field ) { $type = isset( $field['type'] ) ? $field['type'] : ''; $fid = isset( $field['id'] ) ? (int) $field['id'] : 0; $key = 'field_' . $fid; if ( 'payment_single' === $type ) { $price = self::to_amount( isset( $field['price'] ) ? $field['price'] : 0 ); // User-defined amounts (donations) read the visitor's value. if ( ! empty( $field['user_price'] ) && isset( $raw[ $key ] ) ) { $price = self::to_amount( $raw[ $key ] ); } if ( $price > 0 ) { $line = $price * $quantity; $items[] = [ 'label' => isset( $field['label'] ) ? $field['label'] : __( 'Item', 'phemightforms' ), 'amount' => $line, ]; $total += $line; } } if ( 'payment_multiple' === $type || 'payment_select' === $type ) { $selected = isset( $raw[ $key ] ) ? (string) $raw[ $key ] : ''; if ( '' === $selected ) { continue; } $choices = isset( $field['choices'] ) && is_array( $field['choices'] ) ? $field['choices'] : []; foreach ( $choices as $choice ) { list( $label, $price ) = self::parse_choice( $choice ); if ( $label === $selected && $price > 0 ) { $line = $price * $quantity; $items[] = [ 'label' => $label, 'amount' => $line, ]; $total += $line; break; } } } } $coupon = null; $discount = 0.0; // Coupon code arrives on the dedicated coupon field. foreach ( $form->get_fields() as $field ) { if ( ! isset( $field['type'] ) || 'payment_coupon' !== $field['type'] ) { continue; } $key = 'field_' . (int) $field['id']; $code = isset( $raw[ $key ] ) ? sanitize_text_field( $raw[ $key ] ) : ''; if ( '' !== $code ) { $found = self::find_coupon( $code ); if ( $found ) { $coupon = $found; $discount = self::coupon_discount( $total, $found ); } } break; } return [ 'total' => round( max( 0, $total - $discount ), 2 ), 'subtotal' => round( $total, 2 ), 'items' => $items, 'coupon' => $coupon, 'discount' => round( $discount, 2 ), ]; } /** * Look up a coupon by code (case-insensitive). * * @param string $code Coupon code. * @return array|null */ public static function find_coupon( $code ) { $settings = self::settings(); foreach ( (array) $settings['coupons'] as $coupon ) { if ( isset( $coupon['code'] ) && 0 === strcasecmp( trim( $coupon['code'] ), trim( $code ) ) ) { return $coupon; } } return null; } /** * Discount amount a coupon takes off a subtotal. * * @param float $subtotal Subtotal. * @param array $coupon Coupon row. * @return float */ public static function coupon_discount( $subtotal, array $coupon ) { $amount = self::to_amount( isset( $coupon['amount'] ) ? $coupon['amount'] : 0 ); if ( isset( $coupon['type'] ) && 'percent' === $coupon['type'] ) { return round( $subtotal * min( 100, $amount ) / 100, 2 ); } return min( $subtotal, $amount ); } /** * AJAX: validate a coupon code and return its discount details. */ public function ajax_apply_coupon() { $form_id = isset( $_POST['form_id'] ) ? absint( wp_unslash( $_POST['form_id'] ) ) : 0; $nonce = isset( $_POST['nonce'] ) ? sanitize_text_field( wp_unslash( $_POST['nonce'] ) ) : ''; if ( ! $form_id || ! wp_verify_nonce( $nonce, 'phemightforms_submit_' . $form_id ) ) { wp_send_json_error( [ 'message' => __( 'Security check failed.', 'phemightforms' ) ], 403 ); } $code = isset( $_POST['code'] ) ? sanitize_text_field( wp_unslash( $_POST['code'] ) ) : ''; $coupon = '' !== $code ? self::find_coupon( $code ) : null; if ( ! $coupon ) { wp_send_json_error( [ 'message' => __( 'That coupon code is not valid.', 'phemightforms' ) ] ); } wp_send_json_success( [ 'code' => $coupon['code'], 'type' => $coupon['type'], 'amount' => (float) $coupon['amount'], 'label' => 'percent' === $coupon['type'] ? sprintf( /* translators: %s: discount percentage. */ __( '%s%% off', 'phemightforms' ), (float) $coupon['amount'] ) : sprintf( /* translators: %s: discount amount. */ __( '%s off', 'phemightforms' ), self::format( $coupon['amount'] ) ), ] ); } /** * Record a transaction and return its ID. * * @param array $txn Transaction data. * @return string Transaction ID. */ public static function record_transaction( array $txn ) { $transactions = get_option( self::TRANSACTIONS, [] ); $id = 'phf_' . wp_generate_password( 12, false ); $transactions[] = wp_parse_args( $txn, [ 'id' => $id, 'form_id' => 0, 'entry_id' => 0, 'amount' => 0, 'currency' => self::currency(), 'method' => 'offline', 'status' => 'pending', 'coupon' => '', 'date' => current_time( 'mysql' ), ] ); // Keep only the id we generated if the caller did not supply one. $last = count( $transactions ) - 1; if ( empty( $txn['id'] ) ) { $transactions[ $last ]['id'] = $id; } update_option( self::TRANSACTIONS, $transactions ); return $transactions[ $last ]['id']; } /** * All recorded transactions, newest first. * * @return array */ public static function transactions() { return array_reverse( (array) get_option( self::TRANSACTIONS, [] ) ); } /** * Update the status of a transaction by ID. * * @param string $id Transaction ID. * @param string $status New status. */ public static function set_transaction_status( $id, $status ) { $transactions = (array) get_option( self::TRANSACTIONS, [] ); foreach ( $transactions as &$txn ) { if ( isset( $txn['id'] ) && $txn['id'] === $id ) { $txn['status'] = sanitize_key( $status ); } } unset( $txn ); update_option( self::TRANSACTIONS, $transactions ); } /** * Build the checkout redirect URL for a pending payment. * * @param Form $form Form. * @param string $method Provider slug. * @param float $amount Amount due. * @param string $txn_id Transaction ID. * @param string $email Customer email (some gateways require one). * @return string|null Redirect URL, or null when no redirect applies (offline / direct wallet). */ public static function checkout_url( Form $form, $method, $amount, $txn_id, $email = '' ) { $settings = self::settings(); $return_url = add_query_arg( [ 'phf_paid' => $txn_id, 'phf_sig' => self::sign( $txn_id ), ], home_url( '/' ) ); if ( ! is_email( $email ) ) { $email = get_option( 'admin_email' ); } $title = $form->title ? $form->title : __( 'Form payment', 'phemightforms' ); switch ( $method ) { case 'stripe': return self::stripe_checkout( $form, $amount, $txn_id, $return_url, $settings ); case 'paypal': $base = $settings['paypal_sandbox'] ? 'https://www.sandbox.paypal.com/cgi-bin/webscr' : 'https://www.paypal.com/cgi-bin/webscr'; return add_query_arg( [ 'cmd' => '_xclick', 'business' => rawurlencode( $settings['paypal_email'] ), 'item_name' => rawurlencode( $title ), 'amount' => number_format( $amount, 2, '.', '' ), 'currency_code' => self::currency(), 'custom' => $txn_id, 'return' => rawurlencode( $return_url ), 'no_shipping' => 1, ], $base ); case 'paystack': $data = self::post_json( 'https://api.paystack.co/transaction/initialize', [ 'Authorization' => 'Bearer ' . $settings['paystack_secret'] ], [ 'email' => $email, 'amount' => (int) round( $amount * 100 ), 'currency' => self::currency(), 'reference' => $txn_id, 'callback_url' => $return_url, ] ); return isset( $data['data']['authorization_url'] ) ? $data['data']['authorization_url'] : null; case 'flutterwave': $data = self::post_json( 'https://api.flutterwave.com/v3/payments', [ 'Authorization' => 'Bearer ' . $settings['flutterwave_secret'] ], [ 'tx_ref' => $txn_id, 'amount' => number_format( $amount, 2, '.', '' ), 'currency' => self::currency(), 'redirect_url' => $return_url, 'customer' => [ 'email' => $email ], 'customizations' => [ 'title' => $title ], ] ); return isset( $data['data']['link'] ) ? $data['data']['link'] : null; case 'razorpay': $data = self::post_json( 'https://api.razorpay.com/v1/payment_links', [ 'Authorization' => 'Basic ' . base64_encode( $settings['razorpay_key_id'] . ':' . $settings['razorpay_secret'] ) ], // phpcs:ignore WordPress.PHP.DiscouragedPHPFunctions.obfuscation_base64_encode [ 'amount' => (int) round( $amount * 100 ), 'currency' => self::currency(), 'description' => $title, 'reference_id' => $txn_id, 'callback_url' => $return_url, 'callback_method' => 'get', ] ); return isset( $data['short_url'] ) ? $data['short_url'] : null; case 'mollie': $data = self::post_json( 'https://api.mollie.com/v2/payments', [ 'Authorization' => 'Bearer ' . $settings['mollie_key'] ], [ 'amount' => [ 'currency' => self::currency(), 'value' => number_format( $amount, 2, '.', '' ), ], 'description' => $title, 'redirectUrl' => $return_url, 'metadata' => [ 'txn_id' => $txn_id ], ] ); return isset( $data['_links']['checkout']['href'] ) ? $data['_links']['checkout']['href'] : null; case 'square': $data = self::post_json( 'https://connect.squareup.com/v2/online-checkout/payment-links', [ 'Authorization' => 'Bearer ' . $settings['square_token'], 'Square-Version' => '2024-01-18', ], [ 'idempotency_key' => $txn_id, 'quick_pay' => [ 'name' => $title, 'price_money' => [ 'amount' => (int) round( $amount * 100 ), 'currency' => self::currency(), ], 'location_id' => $settings['square_location'], ], 'checkout_options' => [ 'redirect_url' => $return_url ], ] ); return isset( $data['payment_link']['url'] ) ? $data['payment_link']['url'] : null; case 'coinbase': $data = self::post_json( 'https://api.commerce.coinbase.com/charges', [ 'X-CC-Api-Key' => $settings['coinbase_key'], 'X-CC-Version' => '2018-03-22', ], [ 'name' => $title, 'description' => sprintf( /* translators: %s: payment title. */ __( 'Payment for %s', 'phemightforms' ), $title ), 'pricing_type' => 'fixed_price', 'local_price' => [ 'amount' => number_format( $amount, 2, '.', '' ), 'currency' => self::currency(), ], 'redirect_url' => $return_url, 'metadata' => [ 'txn_id' => $txn_id ], ] ); return isset( $data['data']['hosted_url'] ) ? $data['data']['hosted_url'] : null; case 'nowpayments': $data = self::post_json( 'https://api.nowpayments.io/v1/invoice', [ 'x-api-key' => $settings['nowpayments_key'] ], [ 'price_amount' => (float) number_format( $amount, 2, '.', '' ), 'price_currency' => strtolower( self::currency() ), 'order_id' => $txn_id, 'order_description' => $title, 'success_url' => $return_url, 'cancel_url' => home_url( '/' ), ] ); return isset( $data['invoice_url'] ) ? $data['invoice_url'] : null; } return null; } /** * POST a JSON body to a gateway API and decode the JSON response. * * @param string $url Endpoint. * @param array $headers Extra headers (auth). * @param array $body Request body. * @return array|null Decoded response, or null on failure. */ private static function post_json( $url, array $headers, array $body ) { $response = wp_remote_post( $url, [ 'timeout' => 20, 'headers' => array_merge( [ 'Content-Type' => 'application/json' ], $headers ), 'body' => wp_json_encode( $body ), ] ); if ( is_wp_error( $response ) ) { return null; } $data = json_decode( wp_remote_retrieve_body( $response ), true ); return is_array( $data ) ? $data : null; } /** * Create a Stripe Checkout Session and return its URL. * * @param Form $form Form. * @param float $amount Amount due. * @param string $txn_id Transaction ID. * @param string $return_url Success URL. * @param array $settings Payment settings. * @return string|null */ private static function stripe_checkout( Form $form, $amount, $txn_id, $return_url, array $settings ) { $currency = strtolower( self::currency() ); $zero_dec = in_array( $currency, [ 'jpy' ], true ); $unit = $zero_dec ? (int) round( $amount ) : (int) round( $amount * 100 ); $body = [ 'mode' => 'payment', 'success_url' => $return_url, 'cancel_url' => home_url( '/' ), 'client_reference_id' => $txn_id, 'line_items[0][quantity]' => 1, 'line_items[0][price_data][currency]' => $currency, 'line_items[0][price_data][unit_amount]' => $unit, 'line_items[0][price_data][product_data][name]' => $form->title ? $form->title : __( 'Form payment', 'phemightforms' ), ]; $response = wp_remote_post( 'https://api.stripe.com/v1/checkout/sessions', [ 'timeout' => 20, 'headers' => [ 'Authorization' => 'Bearer ' . $settings['stripe_secret'], 'Content-Type' => 'application/x-www-form-urlencoded', ], 'body' => $body, ] ); if ( is_wp_error( $response ) ) { return null; } $data = json_decode( wp_remote_retrieve_body( $response ), true ); return isset( $data['url'] ) ? $data['url'] : null; } /** * HMAC signature so return URLs cannot be forged. * * @param string $txn_id Transaction ID. * @return string */ private static function sign( $txn_id ) { return substr( hash_hmac( 'sha256', $txn_id, wp_salt( 'auth' ) ), 0, 20 ); } /** * Handle the customer return from a hosted checkout page. * * Public GET parameters must never mark a payment as paid. The signed * return URL is only used to show a thank-you notice; status stays * pending until an administrator confirms the payment in the Payments * screen (or a gateway webhook marks it in a future update). */ public function maybe_handle_payment_return() { if ( empty( $_GET['phf_paid'] ) || empty( $_GET['phf_sig'] ) ) { // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- public return URL; signature verified below; does not change payment status. return; } $txn_id = sanitize_text_field( wp_unslash( $_GET['phf_paid'] ) ); // phpcs:ignore WordPress.Security.NonceVerification.Recommended $sig = sanitize_text_field( wp_unslash( $_GET['phf_sig'] ) ); // phpcs:ignore WordPress.Security.NonceVerification.Recommended if ( ! hash_equals( self::sign( $txn_id ), $sig ) ) { return; } // Do not mark paid from this public return URL. Store a short-lived // notice so the confirmation message can be shown to the visitor. set_transient( 'phemightforms_return_' . md5( $txn_id ), [ 'txn_id' => $txn_id, 'message' => __( 'Thank you. Your payment is being confirmed. An administrator will mark it paid once the gateway confirms the charge.', 'phemightforms' ), ], HOUR_IN_SECONDS ); add_action( 'wp_enqueue_scripts', [ $this, 'enqueue_payment_return_assets' ] ); add_action( 'wp_footer', [ $this, 'print_payment_return_notice' ] ); } /** * Enqueue styles for the checkout return notice. */ public function enqueue_payment_return_assets() { wp_register_style( 'phemightforms-payment-return', false, [], PHEMIGHTFORMS_VERSION ); wp_enqueue_style( 'phemightforms-payment-return' ); wp_add_inline_style( 'phemightforms-payment-return', '.phemightforms-payment-return{position:fixed;left:16px;right:16px;bottom:16px;z-index:99999;max-width:520px;margin:0 auto;padding:14px 18px;border-radius:12px;background:#241b10;color:#fff;font:600 14px/1.45 -apple-system,BlinkMacSystemFont,"Segoe UI",sans-serif;box-shadow:0 12px 30px rgba(0,0,0,.25);}' ); } /** * Print a front-end notice after a valid checkout return. */ public function print_payment_return_notice() { if ( empty( $_GET['phf_paid'] ) || empty( $_GET['phf_sig'] ) ) { // phpcs:ignore WordPress.Security.NonceVerification.Recommended return; } $txn_id = sanitize_text_field( wp_unslash( $_GET['phf_paid'] ) ); // phpcs:ignore WordPress.Security.NonceVerification.Recommended $sig = sanitize_text_field( wp_unslash( $_GET['phf_sig'] ) ); // phpcs:ignore WordPress.Security.NonceVerification.Recommended if ( ! hash_equals( self::sign( $txn_id ), $sig ) ) { return; } $data = get_transient( 'phemightforms_return_' . md5( $txn_id ) ); if ( ! is_array( $data ) || empty( $data['message'] ) ) { return; } printf( '
%s
', esc_html( (string) $data['message'] ) ); } }