8 Commits

Author SHA1 Message Date
wpdev ad3f604321 Merge pull request 'release 0.3.1: 2meet-data-optimizer-woocommerce-addon' (#5) from release/0.3.1 into main
Tests / PHP Lint (push) Successful in 10s
Tests / Unit Tests (push) Successful in 1m4s
Tests / Integration Tests (push) Successful in 2m17s
2026-10-02 20:48:58 +08:00
wpdev 5d13f70f16 chore(2meet-data-optimizer-woocommerce-addon): 0.3.1 repackage (no functional change)
Tests / PHP Lint (pull_request) Successful in 8s
Tests / Unit Tests (pull_request) Successful in 1m0s
Tests / Integration Tests (pull_request) Successful in 1m40s
Version bump for a re-release packaged alongside the WP-MVB v8 hub (dev39)
acceptance; code is identical to the previous release.
2026-10-02 17:23:22 +08:00
wpdev 4d3d1774b6 Merge pull request 'hardening 2.3: 2meet-data-optimizer-woocommerce-addon 0.3.0 (commissions follow refunds)' (#4) from hardening/2.3 into main
Tests / PHP Lint (push) Successful in 7s
Tests / Unit Tests (push) Successful in 1m6s
Tests / Integration Tests (push) Successful in 2m2s
2026-09-27 18:37:29 +08:00
wpdev d9d3556579 docs(2meet-data-optimizer-woocommerce-addon): add PR-112 to the 0.3.0 release notes
Tests / PHP Lint (pull_request) Successful in 16s
Tests / Unit Tests (pull_request) Successful in 1m31s
Tests / Integration Tests (pull_request) Successful in 1m57s
- PR: PR-103 (follow-up)
2026-09-27 17:34:19 +08:00
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
wpdev 22afabc649 docs(2meet-data-optimizer-woocommerce-addon): 0.3.0 release notes
- version 0.3.0; CHANGELOG for PR-092 (commissions follow refunds), with the
  note that older rows are corrected on the order's next event
- Verify: composer test (11)
- PR: PR-103
2026-09-27 16:50:29 +08:00
wpdev 50989396a8 fix(2meet-data-optimizer-woocommerce-addon): recompute commission amounts on every tracked status change and refund
- the commission upsert replaces subtotal, commission, vendor_payout and
  commission_rate on conflict (it kept the first amounts, so a partial refund
  before completion never reached the row)
- woocommerce_order_refunded recomputes a tracked or already recorded order;
  a fully refunded order keeps its row at zero, untracked orders get none
- Verify: WCOrdersInterceptorTest (5 new), integration 27, unit 11, phpcs;
  dev36 flow_wc partial refund -> completed
- PR: PR-092
2026-09-27 16:29:28 +08:00
wpdev 6294ee2374 Merge pull request 'hardening 2.2: 2meet-data-optimizer-woocommerce-addon 0.2.0 (WooCommerce meta stays native)' (#3) from hardening/2.2 into main
Tests / PHP Lint (push) Successful in 9s
Tests / Integration Tests (push) Successful in 1m3s
Tests / Unit Tests (push) Failing after 10m9s
2026-09-27 10:56:58 +08:00
4 changed files with 165 additions and 6 deletions
+2 -2
View File
@@ -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.1
* 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.1' );
define( 'TMDO_WOOCOMMERCE_PATH', plugin_dir_path( __FILE__ ) );
define( 'TMDO_WOOCOMMERCE_URL', plugin_dir_url( __FILE__ ) );
define( 'TMDO_WOOCOMMERCE_FILE', __FILE__ );
+34
View File
@@ -11,6 +11,40 @@ Versioning follows [Semantic Versioning](https://semver.org/).
---
## [0.3.1] — 2026-10-02 — 重新打包
### Changed
- 重新打包(無功能變更):與 WP-MVB v8 樞紐站(dev39)一同驗收後發版;程式碼與前一版相同。
---
## [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
+50 -3
View File
@@ -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' )
);
+79 -1
View File
@@ -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() );
}
}