*/ private const TRACKED_STATUSES = array( 'completed', 'processing' ); /** * Registers WordPress hooks for this interceptor. * * @return void */ public function register_hooks(): void { add_action( 'woocommerce_order_status_changed', array( $this, 'action_order_status_changed' ), 10, 3 ); add_action( 'woocommerce_order_refunded', array( $this, 'action_order_refunded' ), 10, 2 ); } /** * Sync order data when WC status changes to a tracked state. * * @param int $order_id Order ID. * @param string $old_status Old status (unused). * @param string $new_status New status. * @return void */ public function action_order_status_changed( int $order_id, string $old_status, string $new_status ): void { // phpcs:ignore Generic.CodeAnalysis.UnusedFunctionParameter.FoundAfterLastUsed if ( ! $this->is_active() ) { return; } if ( ! in_array( $new_status, self::TRACKED_STATUSES, true ) ) { return; } try { $this->sync_commission( $order_id, $new_status ); } catch ( \Throwable $e ) { TMDO_Logger::error( $this->module, 'woocommerce_order_status_changed', $e->getMessage() ); } } /** * Recompute the amounts after a refund, which need not change the status. * Orders never tracked (no row, untracked status) are left alone; a * recorded order that is now fully refunded keeps its row at zero. * * @param int $order_id Order ID. * @param int $refund_id Refund ID (unused). * @return void */ public function action_order_refunded( int $order_id, int $refund_id ): void { // phpcs:ignore Generic.CodeAnalysis.UnusedFunctionParameter.FoundAfterLastUsed if ( ! $this->is_active() || ! function_exists( 'wc_get_order' ) ) { return; } $order = wc_get_order( $order_id ); if ( ! $order ) { return; } $status = (string) $order->get_status(); if ( ! in_array( $status, self::TRACKED_STATUSES, true ) && ! $this->has_commission( $order_id ) ) { return; } try { $this->sync_commission( $order_id, $status ); } catch ( \Throwable $e ) { TMDO_Logger::error( $this->module, 'woocommerce_order_refunded', $e->getMessage() ); } } /** * Whether a commission row exists for the order. * * @param int $wc_order_id WooCommerce order ID. * @return bool */ private function has_commission( int $wc_order_id ): bool { global $wpdb; $table = TMDO_DB::table( 'wpdo_wc_commissions' ); // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching,WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- own table, name from TMDO_DB::table(). return (bool) $wpdb->get_var( $wpdb->prepare( "SELECT 1 FROM `{$table}` WHERE wc_order_id = %d LIMIT 1", $wc_order_id ) ); } /** * INSERT/UPDATE wp_wpdo_wc_commissions for the given order. * * Skips orders without an `hp_vendor` meta — only marketplace orders count. * * @param int $wc_order_id WooCommerce order ID. * @param string $status Order status. * @return void */ private function sync_commission( int $wc_order_id, string $status ): void { if ( ! function_exists( 'wc_get_order' ) ) { return; } $order = wc_get_order( $wc_order_id ); if ( ! $order ) { return; } $vendor_post_id = (int) $order->get_meta( 'hp_vendor' ); if ( $vendor_post_id <= 0 ) { // Not a marketplace order — skip. return; } // hivepress-marketplace stores the vendor's share (0.9 for a 10% commission) and // a flat fee, and pays vendors net_total * share - fee (Marketplace::get_order_profit()). $vendor_share = (float) $order->get_meta( 'hp_commission_rate' ); $fee = (float) $order->get_meta( 'hp_commission_fee' ); $subtotal = (float) $order->get_total() - (float) $order->get_total_refunded(); if ( ! get_option( 'hp_vendor_include_taxes' ) ) { $subtotal -= (float) $order->get_total_tax(); } // Marketplace subtracts the full tax even after a refund; a fully // refunded taxed order would otherwise record a negative amount. $subtotal = max( 0.0, $subtotal ); $vendor_payout = max( 0.0, $subtotal * $vendor_share - $fee ); $commission = $subtotal - $vendor_payout; $commission_rate = round( ( 1 - $vendor_share ) * 100, 2 ); // Platform percentage, as set in Marketplace. // vendor_id holds the vendor's user ID (the admin screen resolves it with get_userdata()); // the listing is the product's parent, as in Marketplace::get_product_listing(). $vendor_id = (int) get_post_field( 'post_author', $vendor_post_id ); $item = current( $order->get_items() ); $listing_id = $item ? (int) get_post_field( 'post_parent', $item->get_product_id() ) : 0; $hpos_enabled = class_exists( 'TMDO_WooCommerce' ) && TMDO_WooCommerce::is_hpos_enabled() ? 1 : 0; $table = TMDO_DB::table( 'wpdo_wc_commissions' ); $now = TMDO_DB::now(); // v2.1.2 race-condition fix: atomic upsert via UNIQUE KEY ui_order_vendor. // Replaces SELECT-then-INSERT/UPDATE which could race two concurrent // status-change events (e.g. gateway IPN + admin click) and trigger // duplicate-key DB errors. Single round-trip; on conflict the amounts are // replaced too, so a later event reflects refunds (2.3 PR-092). TMDO_DB::upsert( $table, array( 'wc_order_id' => $wc_order_id, 'vendor_id' => $vendor_id, 'listing_id' => $listing_id, 'subtotal' => $subtotal, 'commission' => $commission, 'vendor_payout' => $vendor_payout, 'commission_rate' => $commission_rate, 'status' => $status, 'hpos_enabled' => $hpos_enabled, 'created_at' => $now, 'updated_at' => $now, ), array( 'subtotal', 'commission', 'vendor_payout', 'commission_rate', 'status', 'updated_at' ), // columns to update on conflict. array( 'wc_order_id', 'vendor_id' ), // composite unique key. array( '%d', '%d', '%d', '%f', '%f', '%f', '%f', '%s', '%d', '%s', '%s' ) ); } /** * Aggregate commission summary for a vendor. * * @param int $vendor_id Vendor user ID. * @param string $status Optional status filter ('processing' / 'completed' / 'all'). * @return array{total_subtotal:float, total_commission:float, total_payout:float, order_count:int} */ public static function vendor_summary( int $vendor_id, string $status = 'all' ): array { global $wpdb; $table = TMDO_DB::table( 'wpdo_wc_commissions' ); $where = $wpdb->prepare( ' WHERE vendor_id = %d ', $vendor_id ); if ( 'all' !== $status ) { $where .= $wpdb->prepare( ' AND status = %s ', $status ); } // phpcs:ignore WordPress.DB.DirectDatabaseQuery,WordPress.DB.DirectDatabaseQuery.NoCaching,WordPress.DB.PreparedSQL.InterpolatedNotPrepared,WordPress.DB.PreparedSQL.NotPrepared -- {$table} via TMDO_DB::table(); {$where} is a pre-validated SQL fragment. $row = $wpdb->get_row( "SELECT SUM(subtotal) AS total_subtotal, SUM(commission) AS total_commission, SUM(vendor_payout) AS total_payout, COUNT(*) AS order_count FROM `{$table}` {$where}", // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- {$table} via TMDO_DB::table(); {$where} is a pre-validated SQL fragment. ARRAY_A ); return array( 'total_subtotal' => (float) ( $row['total_subtotal'] ?? 0 ), 'total_commission' => (float) ( $row['total_commission'] ?? 0 ), 'total_payout' => (float) ( $row['total_payout'] ?? 0 ), 'order_count' => (int) ( $row['order_count'] ?? 0 ), ); } }