$payload['currency'] ?? null, 'total_minor' => $payload['total_minor'] ?? null, 'line_items' => $payload['line_items'] ?? null, 'shipping' => $payload['shipping'] ?? null, 'phone' => $payload['billing']['phone'] ?? null, ); return (string) $connection_id . '|' . hash( 'sha256', (string) wp_json_encode( $covered ) ); } /** * Whether a stored open value still matches the order as it is now. * * @param string $stored Meta value. * @param string|null $connection_id Current connection id, null when disconnected. * @param array $payload Current order payload. * @return bool */ function orderping_prepay_open_matches( $stored, $connection_id, array $payload ) { return null !== $connection_id && '' !== $stored && hash_equals( orderping_prepay_open_value( $connection_id, $payload ), (string) $stored ); } /** * Whether an order carries a real online payment: a paid date and a non-COD method. * * @param string $method Payment method id. * @param bool $has_paid Whether the order has a date paid. * @return bool */ function orderping_prepay_is_paid_signal( $method, $has_paid ) { return $has_paid && '' !== (string) $method && ! orderping_prepay_is_cod_method( $method ); } /** * Whether the order is paid online. * * @param WC_Order $order Order. * @return bool */ function orderping_prepay_order_is_paid( WC_Order $order ) { return orderping_prepay_is_paid_signal( $order->get_payment_method(), null !== $order->get_date_paid() ); } /** * Whether the order is currently open for online payment. * * @param mixed $order Order (anything else is false). * @return bool */ function orderping_prepay_is_open( $order ) { if ( ! $order instanceof WC_Order ) { return false; } $conn = orderping_connection(); if ( null === $conn ) { return false; } $stored = (string) $order->get_meta( ORDERPING_PREPAY_OPEN_META, true ); if ( '' === $stored ) { return false; } // Once paid online the order is no longer open, so the pay URL cannot start a second payment. if ( orderping_prepay_order_is_paid( $order ) ) { return false; } return orderping_prepay_open_matches( $stored, $conn['id'], orderping_order_payload( $order ) ); } /** * Registers the hooks. Everything is scoped to orders flagged open; other orders are untouched. */ function orderping_prepay_register() { add_filter( 'woocommerce_valid_order_statuses_for_payment', 'orderping_prepay_payable_statuses', 10, 2 ); add_filter( 'woocommerce_valid_order_statuses_for_payment_complete', 'orderping_prepay_complete_statuses', 10, 2 ); add_filter( 'woocommerce_available_payment_gateways', 'orderping_prepay_hide_cod_on_pay_page', 20, 1 ); add_action( 'woocommerce_payment_complete', 'orderping_prepay_on_maybe_paid', 20, 1 ); add_action( 'woocommerce_order_status_changed', 'orderping_prepay_on_maybe_paid', 40, 1 ); } /** * The order-pay page only accepts pending and failed orders. An open COD order is processing or on hold. * * @param array $statuses Statuses. * @param mixed $order Order. * @return array */ function orderping_prepay_payable_statuses( $statuses, $order = null ) { if ( ! orderping_prepay_is_open( $order ) ) { return $statuses; } return array_values( array_unique( array_merge( (array) $statuses, array( 'processing', 'on-hold' ) ) ) ); } /** * payment_complete() refuses a processing order unless its status is listed. * * @param array $statuses Statuses. * @param mixed $order Order. * @return array */ function orderping_prepay_complete_statuses( $statuses, $order = null ) { if ( ! orderping_prepay_is_open( $order ) ) { return $statuses; } return array_values( array_unique( array_merge( (array) $statuses, array( 'processing' ) ) ) ); } /** * Removes cash on delivery from the order-pay page of an open order, so the customer cannot pay "online" * by picking COD again. * * @param array $gateways Available gateways. * @return array */ function orderping_prepay_hide_cod_on_pay_page( $gateways ) { if ( ! is_array( $gateways ) || ! function_exists( 'is_wc_endpoint_url' ) || ! is_wc_endpoint_url( 'order-pay' ) ) { return $gateways; } $order_id = absint( get_query_var( 'order-pay' ) ); if ( $order_id < 1 || ! orderping_prepay_is_open( wc_get_order( $order_id ) ) ) { return $gateways; } foreach ( orderping_prepay_cod_methods() as $id ) { unset( $gateways[ $id ] ); } return $gateways; } /** * Payment hook: when a captured order now carries an online payment, reports it once. * The event key carries the paid date, so a retried hook cannot enqueue the same payment twice. * * @param int $order_id Order ID. */ function orderping_prepay_on_maybe_paid( $order_id ) { try { $order = wc_get_order( $order_id ); if ( ! $order instanceof WC_Order || null === orderping_connection() || ! orderping_order_reportable( $order ) ) { return; } if ( ! orderping_prepay_order_is_paid( $order ) ) { return; } // Only orders a pay link was created for are reported; others never reach the backend. if ( '' === (string) $order->get_meta( ORDERPING_PREPAY_OPEN_META, true ) ) { return; } $paid =$order->get_date_paid(); $key = 'order-paid-' . (int) $order_id . '-' . (int) $paid->getTimestamp(); orderping_outbox_enqueue( $key, (int) $order_id, array( 'type' => 'order_paid', 'event_id' => $key, 'contract_version' => ORDERPING_CONTRACT_VERSION, 'plugin_version' => ORDERPING_VERSION, 'order_id' => (string) (int) $order_id, 'payment_method' => (string) $order->get_payment_method(), 'date_paid' => gmdate( 'Y-m-d\TH:i:s\Z', (int) $paid->getTimestamp() ), ) ); } catch ( \Throwable $e ) { return; // A payment must never fail because of this plugin. } } /** * Validates a signed backend request for one of the prepay routes. * * @param WP_REST_Request $request Request. * @param string $path Logical path the signature covers. * @return array|WP_Error Decoded body. */ function orderping_prepay_authenticate( WP_REST_Request $request, $path ) { $conn = orderping_connection(); if ( null === $conn || $conn['id'] !== (string) $request->get_header( 'x_op_connection' ) ) { return new WP_Error( 'orderping_unauthorized', 'Unauthorized', array( 'status' => 401 ) ); } $body = $request->get_body(); $verdict = orderping_verify( $conn['secret'], 'cp-to-site', 'POST', $path, (string) $request->get_header( 'x_op_timestamp' ), (string) $request->get_header( 'x_op_nonce' ), (string) $request->get_header( 'x_op_signature' ), $body, time() ); if ( true !== $verdict ) { return new WP_Error( 'orderping_unauthorized', 'Unauthorized', array( 'status' => 401 ) ); } // Claim the nonce only after the signature is valid. if ( ! orderping_claim_nonce( (string) $request->get_header( 'x_op_nonce' ) ) ) { return new WP_Error( 'orderping_replayed', 'Unauthorized', array( 'status' => 401 ) ); } $data = json_decode( $body, true ); return is_array( $data ) ? $data : new WP_Error( 'orderping_invalid', 'Invalid request', array( 'status' => 400 ) ); } /** * Validates the shared request fields. * * @param array $data Decoded body. * @return bool */ function orderping_prepay_request_shape_ok( array $data ) { return 1 === ( $data['contract_version'] ?? null ) && is_string( $data['order_id'] ?? null ) && 1 === preg_match( '/^\d{1,20}$/', $data['order_id'] ); } /** * Whether the write-back key has the shape the backend issues. * * @param mixed $key Key. * @return bool */ function orderping_prepay_key_ok( $key ) { return is_string( $key ) && 1 === preg_match( '/^[A-Za-z0-9:_.-]{1,160}$/', $key ); } /** * Signed pay-link lookup. Returns open + the order's own pay URL, paid, or closed. * Opening records the fingerprint the customer was shown; it never changes the order's status. * * @param WP_REST_Request $request Request. * @return WP_REST_Response|WP_Error */ function orderping_rest_prepay_link( WP_REST_Request $request ) { $data = orderping_prepay_authenticate( $request, ORDERPING_PREPAY_LINK_PATH ); if ( is_wp_error( $data ) ) { return $data; } if ( ! orderping_prepay_request_shape_ok( $data ) ) { return new WP_Error( 'orderping_invalid', 'Invalid request', array( 'status' => 400 ) ); } $order = orderping_fresh_order( (int) $data['order_id'] ); // Pilot scope and capture first: nothing about any other order is read or written. if ( ! $order instanceof WC_Order || ! orderping_order_in_scope( $order ) || ! orderping_order_was_captured( $order->get_id() ) ) { return new WP_Error( 'orderping_not_found', 'Order not found', array( 'status' => 404 ) ); } if ( orderping_prepay_order_is_paid( $order ) ) { return new WP_REST_Response( array( 'state' => 'paid' ), 200 ); } $conn = orderping_connection(); if ( null === $conn || ! in_array( $order->get_status(), ORDERPING_PREPAY_OPEN_STATUSES, true ) || ! orderping_prepay_is_cod_method( $order->get_payment_method() ) || (float) $order->get_total() <= 0 ) { return new WP_REST_Response( array( 'state' => 'closed' ), 200 ); } $value = orderping_prepay_open_value( $conn['id'], orderping_order_payload( $order ) ); if ( (string) $order->get_meta( ORDERPING_PREPAY_OPEN_META, true ) !== $value ) { $order->update_meta_data( ORDERPING_PREPAY_OPEN_META, $value ); $order->save_meta_data(); } return new WP_REST_Response( array( 'state' => 'open', 'url' => $order->get_checkout_payment_url(), ), 200 ); } /** * Signed write-back: one order note and meta per key. Repeating a key changes nothing. * It never changes the order's status or payment; it only records that a real payment arrived. * * @param WP_REST_Request $request Request. * @return WP_REST_Response|WP_Error */ function orderping_rest_prepay_writeback( WP_REST_Request $request ) { $data = orderping_prepay_authenticate( $request, ORDERPING_PREPAY_WRITEBACK_PATH ); if ( is_wp_error( $data ) ) { return $data; } if ( ! orderping_prepay_request_shape_ok( $data ) || ! orderping_prepay_key_ok( $data['key'] ?? null ) ) { return new WP_Error( 'orderping_invalid', 'Invalid request', array( 'status' => 400 ) ); } $order = orderping_fresh_order( (int) $data['order_id'] ); if ( ! $order instanceof WC_Order || ! orderping_order_in_scope( $order ) || ! orderping_order_was_captured( $order->get_id() ) ) { return new WP_Error( 'orderping_not_found', 'Order not found', array( 'status' => 404 ) ); } $applied = $order->get_meta( ORDERPING_PREPAY_NOTES_META, true ); $applied = is_array( $applied ) ? $applied : array(); if ( in_array( $data['key'], $applied, true ) ) { return new WP_REST_Response( array( 'state' => 'applied' ), 200 ); } if ( ! orderping_prepay_order_is_paid( $order ) ) { return new WP_REST_Response( array( 'state' => 'failed' ), 200 ); } // Record the key first: a crash between the two writes then loses a note, never duplicates one. $applied[] = $data['key']; $order->update_meta_data( ORDERPING_PREPAY_NOTES_META, $applied ); $order->update_meta_data( ORDERPING_PREPAY_STATE_META, 'paid' ); $order->save_meta_data(); $order->add_order_note( __( 'CommercePing: the customer paid this order online after the WhatsApp payment prompt.', 'commerceping' ) ); return new WP_REST_Response( array( 'state' => 'applied' ), 200 ); } /** * Validates the COD-to-prepaid settings block. The backend applies the full incentive rules * (no links, 120 characters) and answers 422 with a message; this only bounds the input. * * @param array $in Raw input: enabled, incentive, reminder_hours. * @return array{ok:bool,value?:array,error?:string} */ function orderping_validate_cod_prepay( array $in ) { $hours = orderping_parse_whole( $in['reminder_hours'] ?? null ); if ( null === $hours || $hours < 1 || $hours > 72 ) { return array( 'ok' => false, 'error' => __( 'The payment reminder time must be a whole number of hours from 1 to 72.', 'commerceping' ) ); } $incentive = trim( (string) preg_replace( '/\s+/', ' ', is_array( $in['incentive'] ?? null ) ? '' : (string) ( $in['incentive'] ?? '' ) ) ); if ( strlen( $incentive ) > 400 ) { return array( 'ok' => false, 'error' => __( 'The payment offer text is too long. Keep it under 120 characters.', 'commerceping' ) ); } return array( 'ok' => true, 'value' => array( 'enabled' => ! empty( $in['enabled'] ), 'incentive' => '' === $incentive ? null : $incentive, 'reminder_hours' => $hours, ), ); } /** * Prints the settings rows for the feature. Shown only when the backend reported the block. * * @param array $cod Settings block: enabled, incentive, reminder_hours. */ function orderping_render_cod_prepay_settings( array $cod ) { ?>