Files
2meet-data-optimizer-woocom…/includes/class-tmdo-wc-orders-interceptor.php
T
wpdev c2bd02e9ec fix(2meet-data-optimizer-woocommerce-addon): record no negative commission for a fully refunded taxed order
- the amounts follow Marketplace::get_order_profit() (what the vendor is
  paid), which subtracts the full order tax after refunds; a fully refunded
  taxed order came out at subtotal = commission = -tax. The subtotal is now
  floored at 0 (security review M2); partial refunds keep matching what
  Marketplace pays
- Verify: WCOrdersInterceptorTest (full refund with tax), integration 28, unit 11, phpcs
- PR: PR-112 (2.3 acceptance security review)
2026-09-27 17:23:31 +08:00

231 lines
8.5 KiB
PHP

<?php
/**
* TMDO_WC_Orders_Interceptor — vendor commission tracking for WC marketplaces.
*
* V2.1.0 rewrite: replaces legacy `hpct_wc_orders` write path with own
* `wp_wpdo_wc_commissions` table. HPOS-aware (uses `wc_get_order()`).
*
* Trigger: `woocommerce_order_status_changed` → on `processing`/`completed`
* read hivepress-marketplace's order meta (`hp_vendor`,
* `hp_commission_rate`, `hp_commission_fee`) and INSERT/UPDATE
* `wp_wpdo_wc_commissions`.
*
* Reads from order via `wc_get_order()` — works for both HPOS and legacy.
*
* @package TMDO
* @since 1.0.0 (legacy hpct_wc_orders write path)
* @since 2.1.0 (wp_wpdo_wc_commissions custom table; HPOS-aware)
*/
if ( ! defined( 'ABSPATH' ) ) {
exit;
}
/**
* WC Orders interceptor — modern v2 model.
*
* Operates on `wp_wpdo_wc_commissions` (own custom table). HPCT legacy code removed.
*/
class TMDO_WC_Orders_Interceptor extends TMDO_Interceptor_Base {
/**
* Module identifier.
*
* @var string
*/
protected string $module = 'wc_orders';
/**
* Order statuses that trigger commission recording.
*
* @var array<string>
*/
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 ),
);
}
}