diff --git a/2meet-data-optimizer-woocommerce-addon.php b/2meet-data-optimizer-woocommerce-addon.php index 18f80fd..a0ccb88 100644 --- a/2meet-data-optimizer-woocommerce-addon.php +++ b/2meet-data-optimizer-woocommerce-addon.php @@ -3,7 +3,7 @@ * Plugin Name: 2meet Data Optimizer — Woocommerce AddOn * Plugin URI: https://2meet.io/2meet-data-optimizer/woocommerce * Description: WooCommerce 整合:20 表 schema 註冊 + commission tracking (wp_wpdo_wc_commissions) + product/order zone + HPOS notice - * Version: 0.2.0 + * Version: 0.3.0 * Requires at least: 6.0 * Tested up to: 6.9.4 * Requires PHP: 8.1 @@ -24,7 +24,7 @@ if ( ! defined( 'ABSPATH' ) ) { exit; } -define( 'TMDO_WOOCOMMERCE_VERSION', '0.2.0' ); +define( 'TMDO_WOOCOMMERCE_VERSION', '0.3.0' ); define( 'TMDO_WOOCOMMERCE_PATH', plugin_dir_path( __FILE__ ) ); define( 'TMDO_WOOCOMMERCE_URL', plugin_dir_url( __FILE__ ) ); define( 'TMDO_WOOCOMMERCE_FILE', __FILE__ ); diff --git a/CHANGELOG.md b/CHANGELOG.md index cdb8fa6..6657960 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,33 @@ Versioning follows [Semantic Versioning](https://semver.org/). --- +## [0.3.0] — 2026-09-27 + +Released with 2meet-data-optimizer 1.3.0 (hardening 2.3). + +### Fixed + +- Commission amounts follow refunds. The commission upsert kept the amounts of + the first tracked event (it only updated `status` / `updated_at`), so a + partial refund before completion left the vendor payout overstated. It now + replaces `subtotal`, `commission`, `vendor_payout` and `commission_rate` too, + and `woocommerce_order_refunded` recomputes an order that is in a tracked + status or already has a row (a fully refunded order keeps its row at zero, + status `refunded`; untracked orders get none). Repeated events still leave + one row per order and vendor. dev36: partial refund then completion records + the refunded amount. (PR-092) +- Amounts follow Marketplace::get_order_profit() (what the vendor is paid, + which subtracts the full tax after refunds); the subtotal is floored at 0, + so a fully refunded taxed order no longer records a negative commission. + (PR-112) + +### Upgrading + +- Rows recorded before 0.3.0 are corrected on the order's next tracked status + change or refund; there is no bulk recompute. + +--- + ## [0.2.0] — 2026-09-27 Released with 2meet-data-optimizer 1.2.0 (hardening 2.2, search-index mode diff --git a/includes/class-tmdo-wc-orders-interceptor.php b/includes/class-tmdo-wc-orders-interceptor.php index cda335b..068e066 100644 --- a/includes/class-tmdo-wc-orders-interceptor.php +++ b/includes/class-tmdo-wc-orders-interceptor.php @@ -49,6 +49,7 @@ class TMDO_WC_Orders_Interceptor extends TMDO_Interceptor_Base { */ 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 ); } /** @@ -74,6 +75,48 @@ class TMDO_WC_Orders_Interceptor extends TMDO_Interceptor_Base { } } + /** + * 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. * @@ -106,6 +149,10 @@ class TMDO_WC_Orders_Interceptor extends TMDO_Interceptor_Base { 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. @@ -123,8 +170,8 @@ class TMDO_WC_Orders_Interceptor extends TMDO_Interceptor_Base { // 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 only updates - // the mutable columns (status / updated_at), preserves financials. + // 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( @@ -140,7 +187,7 @@ class TMDO_WC_Orders_Interceptor extends TMDO_Interceptor_Base { 'created_at' => $now, 'updated_at' => $now, ), - array( 'status', 'updated_at' ), // columns to update on conflict. + 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' ) ); diff --git a/tests/integration/WCOrdersInterceptorTest.php b/tests/integration/WCOrdersInterceptorTest.php index 09230ea..0366e34 100644 --- a/tests/integration/WCOrdersInterceptorTest.php +++ b/tests/integration/WCOrdersInterceptorTest.php @@ -25,9 +25,14 @@ if ( ! class_exists( 'TMDO_Test_WC_Order' ) ) { private float $total, private float $tax, private float $refunded, - private int $product_id + private int $product_id, + private string $status = 'processing' ) {} + public function get_status(): string { + return $this->status; + } + public function get_meta( string $key ) { return $this->meta[ $key ] ?? ''; } @@ -106,6 +111,11 @@ class WCOrdersInterceptorTest extends TestCase { ( new TMDO_WC_Orders_Interceptor() )->action_order_status_changed( self::ORDER_ID, 'pending', 'processing' ); } + /** The same order after a refund (and, for a full refund, a new status). */ + private function refund( float $refunded, string $status ): void { + $GLOBALS['_tmdo_test_orders'][ self::ORDER_ID ] = new TMDO_Test_WC_Order( $this->marketplace_meta(), 110.0, 10.0, $refunded, self::PRODUCT_ID, $status ); + } + private function commission_row(): ?array { global $wpdb; return $wpdb->get_row( 'SELECT * FROM `wp_itest_wpdo_wc_commissions` WHERE wc_order_id = ' . self::ORDER_ID, ARRAY_A ); @@ -157,4 +167,72 @@ class WCOrdersInterceptorTest extends TestCase { $this->assertEqualsWithDelta( 0.0, (float) $row['vendor_payout'], 0.0001 ); $this->assertEqualsWithDelta( 3.0, (float) $row['commission'], 0.0001 ); } + + public function test_partial_refund_then_completion_records_the_refunded_amounts(): void { + $this->place_order( $this->marketplace_meta(), 110.0, 10.0 ); + $this->refund( 50.0, 'processing' ); + $interceptor = new TMDO_WC_Orders_Interceptor(); + $interceptor->action_order_status_changed( self::ORDER_ID, 'processing', 'completed' ); + $interceptor->action_order_status_changed( self::ORDER_ID, 'processing', 'completed' ); // Duplicate event. + + global $wpdb; + $this->assertSame( '1', (string) $wpdb->get_var( 'SELECT COUNT(*) FROM `wp_itest_wpdo_wc_commissions` WHERE wc_order_id = ' . self::ORDER_ID ) ); + $row = $this->commission_row(); + $this->assertSame( 'completed', $row['status'] ); + $this->assertEqualsWithDelta( 50.0, (float) $row['subtotal'], 0.0001, '110 - 50 refunded - 10 tax' ); + $this->assertEqualsWithDelta( 40.0, (float) $row['vendor_payout'], 0.0001 ); + $this->assertEqualsWithDelta( 10.0, (float) $row['commission'], 0.0001 ); + } + + public function test_a_refund_recomputes_the_commission_without_a_status_change(): void { + $this->place_order( $this->marketplace_meta(), 110.0, 10.0 ); + $this->refund( 50.0, 'processing' ); + + ( new TMDO_WC_Orders_Interceptor() )->action_order_refunded( self::ORDER_ID, 1 ); + + $this->assertEqualsWithDelta( 50.0, (float) $this->commission_row()['subtotal'], 0.0001 ); + } + + public function test_a_full_refund_zeroes_a_recorded_commission(): void { + $this->place_order( $this->marketplace_meta(), 110.0, 10.0 ); + $this->refund( 110.0, 'refunded' ); + + ( new TMDO_WC_Orders_Interceptor() )->action_order_refunded( self::ORDER_ID, 1 ); + + $row = $this->commission_row(); + $this->assertSame( 'refunded', $row['status'] ); + $this->assertEqualsWithDelta( 0.0, (float) $row['vendor_payout'], 0.0001 ); + } + + /** + * Amounts follow Marketplace::get_order_profit() (what the vendor is paid, + * which subtracts the full tax after refunds), but a fully refunded taxed + * order must not record a negative subtotal or commission. + */ + public function test_a_fully_refunded_taxed_order_records_no_negative_amounts(): void { + $this->place_order( $this->marketplace_meta(), 110.0, 10.0 ); + $this->refund( 110.0, 'refunded' ); + + ( new TMDO_WC_Orders_Interceptor() )->action_order_refunded( self::ORDER_ID, 1 ); + + $row = $this->commission_row(); + $this->assertEqualsWithDelta( 0.0, (float) $row['subtotal'], 0.0001 ); + $this->assertEqualsWithDelta( 0.0, (float) $row['commission'], 0.0001 ); + $this->assertEqualsWithDelta( 0.0, (float) $row['vendor_payout'], 0.0001 ); + } + + public function test_a_refund_of_an_untracked_order_records_nothing(): void { + $this->refund( 50.0, 'pending' ); + + ( new TMDO_WC_Orders_Interceptor() )->action_order_refunded( self::ORDER_ID, 1 ); + + $this->assertNull( $this->commission_row() ); + } + + public function test_refunds_are_hooked(): void { + $interceptor = new TMDO_WC_Orders_Interceptor(); + $interceptor->register_hooks(); + + $this->assertContains( array( $interceptor, 'action_order_refunded' ), $GLOBALS['_wp_filter_callbacks']['woocommerce_order_refunded'] ?? array() ); + } }