Compare commits
11 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| ad3f604321 | |||
| 5d13f70f16 | |||
| 4d3d1774b6 | |||
| d9d3556579 | |||
| c2bd02e9ec | |||
| 22afabc649 | |||
| 50989396a8 | |||
| 6294ee2374 | |||
| 655ab88e86 | |||
| c3c4e1de44 | |||
| c76e474cbc |
@@ -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.1.1
|
||||
* 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.1.1' );
|
||||
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__ );
|
||||
|
||||
@@ -11,6 +11,70 @@ 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
|
||||
decision 2: WooCommerce meta stays native).
|
||||
|
||||
### Removed
|
||||
|
||||
- WooCommerce meta is no longer copied to flat tables. WooCommerce reads its
|
||||
meta directly and writes stock / sales by SQL, so a flat copy drifted
|
||||
(R-2.1: stock and `total_sales` out of step after an order). No longer
|
||||
registered:
|
||||
- product and order zone fields (`wpdo_register_fields`), including the
|
||||
subscription fields;
|
||||
- the `user` / `hot` entity group (`wpdo_register_entity_fields`),
|
||||
including the three fields added in 0.1.1.
|
||||
- Kept: the `wp_wc_*` custom table registry, the HPOS notice, the
|
||||
Marketplace commission tracking and the term count filter.
|
||||
|
||||
### Upgrading
|
||||
|
||||
- Nothing is dropped. With the user entity below `aeav_only`, WooCommerce keeps
|
||||
reading native user meta and the `wp_wpdo_user_hot` table is simply no
|
||||
longer written. In `aeav_only`, core restores the group as an orphan so its
|
||||
values stay readable; `wp tmdo bridge-restore-native user --group=hot --retire`
|
||||
writes them back to native and archives the table.
|
||||
- Product flat data (core's former `post/wc_product` group): see
|
||||
2meet-data-optimizer 1.2.0.
|
||||
|
||||
---
|
||||
|
||||
## [0.1.1] — 2026-09-26
|
||||
|
||||
Hardening 2.0 changes, released alongside 2meet-data-optimizer 1.1.0.
|
||||
|
||||
@@ -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' )
|
||||
);
|
||||
|
||||
@@ -6,10 +6,9 @@
|
||||
* `wp_wc_product_meta_lookup` for product search). WPDO's job is to:
|
||||
* 1. Register the 20 `wp_wc_*` tables to Custom_Table_Registry
|
||||
* (so doctor / conflict-scan know about them)
|
||||
* 2. Add hot-zone fields to Schema_Registry for the legacy postmeta keys
|
||||
* WC still uses on products / orders (when HPOS off)
|
||||
* 3. Detect HPOS state and surface it to admin
|
||||
* 4. NOT duplicate what WC already does (no shadow lookup tables)
|
||||
* 2. Detect HPOS state and surface it to admin
|
||||
* 3. NOT duplicate what WC already does (no shadow lookup tables, and since
|
||||
* 0.2.0 no flat copy of product / order / customer meta)
|
||||
*
|
||||
* This is "Tier 5" in ENTITY_ADAPTER_COOKBOOK.md: integrating with a plugin
|
||||
* that has its own anti-EAV solution.
|
||||
@@ -171,11 +170,9 @@ final class TMDO_WooCommerce {
|
||||
}
|
||||
|
||||
add_action( 'wpdo_register_custom_tables', array( __CLASS__, 'register_custom_tables' ) );
|
||||
add_action( 'wpdo_register_fields', array( __CLASS__, 'register_schema_fields' ) );
|
||||
// v2.1.6: customer hot fields (_money_spent / _order_count / _last_order) move
|
||||
// to Entity_Registry so Hook Bus can route them to wp_wpdo_user_hot once
|
||||
// `entity_user` mode advances past `disabled`. See PLAN-entity-user-cutover.md.
|
||||
add_action( 'wpdo_register_entity_fields', array( __CLASS__, 'register_user_entity_fields' ) );
|
||||
// 0.2.0: no product / order / subscription zone fields and no user hot group.
|
||||
// WooCommerce reads its meta directly and writes stock by SQL, so a flat
|
||||
// copy drifts; its meta stays native only (core 1.2.0 dropped wc_product too).
|
||||
|
||||
// HPOS recommendation notice — only on WC admin pages, only when:
|
||||
// (a) HPOS is OFF, (b) the threshold of legacy orders has been crossed.
|
||||
@@ -339,328 +336,6 @@ final class TMDO_WooCommerce {
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Register hot-zone meta fields for products + orders + customers.
|
||||
*
|
||||
* Delegates to dedicated registrars to keep this method readable.
|
||||
*
|
||||
* @param mixed $schema TMDO_Schema_Registry (passed by action).
|
||||
* @return void
|
||||
*/
|
||||
public static function register_schema_fields( $schema = null ): void {
|
||||
if ( ! class_exists( 'TMDO_Schema_Registry' ) ) {
|
||||
return;
|
||||
}
|
||||
$schema = $schema ?: TMDO_Schema_Registry::instance();
|
||||
|
||||
self::register_product_fields( $schema );
|
||||
|
||||
// Order fields only matter when HPOS is OFF (otherwise WC stores them in wc_orders_meta).
|
||||
if ( ! self::is_hpos_enabled() ) {
|
||||
self::register_order_fields( $schema );
|
||||
}
|
||||
|
||||
// v2.1.6: Customer usermeta hot fields moved out of Schema_Registry — they
|
||||
// are now registered via `register_user_entity_fields()` on the
|
||||
// `wpdo_register_entity_fields` action so Hook Bus can intercept them.
|
||||
// Schema_Registry never had a user-meta interceptor; the previous
|
||||
// registration was inert. See PLAN-entity-user-cutover.md.
|
||||
|
||||
// Subscription fields if WC Subscriptions OR wc-linepay-subscription is active.
|
||||
if ( self::has_subscriptions() ) {
|
||||
self::register_subscription_fields( $schema );
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Product hot fields (search/filter shop pages).
|
||||
*
|
||||
* @param TMDO_Schema_Registry $schema Registry.
|
||||
* @return void
|
||||
*/
|
||||
private static function register_product_fields( $schema ): void {
|
||||
$schema->register_many(
|
||||
self::PROVIDER,
|
||||
array(
|
||||
array(
|
||||
'post_type' => 'product',
|
||||
'meta_key' => '_price',
|
||||
'zone' => 'hot',
|
||||
'data_type' => 'decimal(15,4) NOT NULL DEFAULT 0',
|
||||
'column' => '_price',
|
||||
'indexed' => true,
|
||||
),
|
||||
array(
|
||||
'post_type' => 'product',
|
||||
'meta_key' => '_regular_price',
|
||||
'zone' => 'hot',
|
||||
'data_type' => 'decimal(15,4) NOT NULL DEFAULT 0',
|
||||
'column' => '_regular_price',
|
||||
),
|
||||
array(
|
||||
'post_type' => 'product',
|
||||
'meta_key' => '_sale_price',
|
||||
'zone' => 'hot',
|
||||
'data_type' => 'decimal(15,4) NULL',
|
||||
'column' => '_sale_price',
|
||||
),
|
||||
array(
|
||||
'post_type' => 'product',
|
||||
'meta_key' => '_sku',
|
||||
'zone' => 'hot',
|
||||
'data_type' => "varchar(100) NOT NULL DEFAULT ''",
|
||||
'column' => '_sku',
|
||||
'indexed' => true,
|
||||
),
|
||||
array(
|
||||
'post_type' => 'product',
|
||||
'meta_key' => '_stock',
|
||||
'zone' => 'hot',
|
||||
'data_type' => 'decimal(15,4) NULL',
|
||||
'column' => '_stock',
|
||||
),
|
||||
array(
|
||||
'post_type' => 'product',
|
||||
'meta_key' => '_stock_status',
|
||||
'zone' => 'hot',
|
||||
'data_type' => "varchar(20) NOT NULL DEFAULT 'instock'",
|
||||
'column' => '_stock_status',
|
||||
'indexed' => true,
|
||||
),
|
||||
array(
|
||||
'post_type' => 'product',
|
||||
'meta_key' => '_visibility',
|
||||
'zone' => 'hot',
|
||||
'data_type' => "varchar(20) NOT NULL DEFAULT 'visible'",
|
||||
'column' => '_visibility',
|
||||
),
|
||||
array(
|
||||
'post_type' => 'product',
|
||||
'meta_key' => '_featured',
|
||||
'zone' => 'hot',
|
||||
'data_type' => "varchar(3) NOT NULL DEFAULT 'no'",
|
||||
'column' => '_featured',
|
||||
'indexed' => true,
|
||||
),
|
||||
array(
|
||||
'post_type' => 'product',
|
||||
'meta_key' => '_tax_status',
|
||||
'zone' => 'hot',
|
||||
'data_type' => "varchar(20) NOT NULL DEFAULT 'taxable'",
|
||||
'column' => '_tax_status',
|
||||
),
|
||||
array(
|
||||
'post_type' => 'product',
|
||||
'meta_key' => '_virtual',
|
||||
'zone' => 'hot',
|
||||
'data_type' => "varchar(3) NOT NULL DEFAULT 'no'",
|
||||
'column' => '_virtual',
|
||||
),
|
||||
array(
|
||||
'post_type' => 'product',
|
||||
'meta_key' => '_downloadable',
|
||||
'zone' => 'hot',
|
||||
'data_type' => "varchar(3) NOT NULL DEFAULT 'no'",
|
||||
'column' => '_downloadable',
|
||||
),
|
||||
)
|
||||
);
|
||||
|
||||
// Cold zone — large display fields rarely searched but always shown.
|
||||
$schema->register_many(
|
||||
self::PROVIDER,
|
||||
array(
|
||||
array(
|
||||
'post_type' => 'product',
|
||||
'meta_key' => '_product_attributes',
|
||||
'zone' => 'cold',
|
||||
'cache_group' => 'wpdo_cold_product',
|
||||
'cache_ttl' => HOUR_IN_SECONDS,
|
||||
),
|
||||
array(
|
||||
'post_type' => 'product',
|
||||
'meta_key' => '_purchase_note',
|
||||
'zone' => 'cold',
|
||||
'cache_group' => 'wpdo_cold_product',
|
||||
'cache_ttl' => HOUR_IN_SECONDS,
|
||||
),
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Order hot fields (legacy postmeta path; only registered when HPOS off).
|
||||
*
|
||||
* @param TMDO_Schema_Registry $schema Registry.
|
||||
* @return void
|
||||
*/
|
||||
private static function register_order_fields( $schema ): void {
|
||||
$schema->register_many(
|
||||
self::PROVIDER,
|
||||
array(
|
||||
array(
|
||||
'post_type' => 'shop_order',
|
||||
'meta_key' => '_order_total',
|
||||
'zone' => 'hot',
|
||||
'data_type' => 'decimal(15,4) NOT NULL DEFAULT 0',
|
||||
'column' => '_order_total',
|
||||
'indexed' => true,
|
||||
),
|
||||
array(
|
||||
'post_type' => 'shop_order',
|
||||
'meta_key' => '_order_currency',
|
||||
'zone' => 'hot',
|
||||
'data_type' => "varchar(8) NOT NULL DEFAULT ''",
|
||||
'column' => '_order_currency',
|
||||
),
|
||||
array(
|
||||
'post_type' => 'shop_order',
|
||||
'meta_key' => '_billing_email',
|
||||
'zone' => 'hot',
|
||||
'data_type' => "varchar(254) NOT NULL DEFAULT ''",
|
||||
'column' => '_billing_email',
|
||||
'indexed' => true,
|
||||
),
|
||||
array(
|
||||
'post_type' => 'shop_order',
|
||||
'meta_key' => '_payment_method',
|
||||
'zone' => 'hot',
|
||||
'data_type' => "varchar(60) NOT NULL DEFAULT ''",
|
||||
'column' => '_payment_method',
|
||||
'indexed' => true,
|
||||
),
|
||||
array(
|
||||
'post_type' => 'shop_order',
|
||||
'meta_key' => '_customer_user',
|
||||
'zone' => 'hot',
|
||||
'data_type' => 'bigint(20) NOT NULL DEFAULT 0',
|
||||
'column' => '_customer_user',
|
||||
'indexed' => true,
|
||||
),
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Customer usermeta hot fields — registered to Entity_Registry so Hook Bus can
|
||||
* route them to `wp_wpdo_user_hot` once `entity_user` mode advances past `disabled`.
|
||||
*
|
||||
* Replaces the v2.1.5 `register_customer_fields()` which targeted Schema_Registry
|
||||
* (zone-based, no user interceptor) — that path was inert. See
|
||||
* PLAN-entity-user-cutover.md for the migration playbook.
|
||||
*
|
||||
* Type mapping (Entity_Registry uses fixed-precision DECIMAL(18,6) / BIGINT(20),
|
||||
* which is a superset of the prior `decimal(15,4)` / `bigint(20)` declarations —
|
||||
* no precision loss):
|
||||
* - _money_spent → decimal (DECIMAL(18,6))
|
||||
* - _order_count → integer (BIGINT(20))
|
||||
* - _last_order → integer (BIGINT(20))
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
public static function register_user_entity_fields(): void {
|
||||
if ( ! class_exists( 'TMDO_Entity_Registry' ) ) {
|
||||
return;
|
||||
}
|
||||
TMDO_Entity_Registry::register_group(
|
||||
'user',
|
||||
'hot',
|
||||
array(
|
||||
array(
|
||||
'key' => '_money_spent',
|
||||
'type' => 'decimal',
|
||||
'searchable' => true,
|
||||
'label' => 'WC customer cumulative spend',
|
||||
),
|
||||
array(
|
||||
'key' => '_order_count',
|
||||
'type' => 'integer',
|
||||
'searchable' => true,
|
||||
'label' => 'WC customer order count',
|
||||
),
|
||||
array(
|
||||
'key' => '_last_order',
|
||||
'type' => 'integer',
|
||||
'label' => 'WC customer last order ID',
|
||||
),
|
||||
// WC runtime stats — written on order/session events, high-frequency
|
||||
// on busy stores. Unregistered they land back in wp_usermeta.
|
||||
array(
|
||||
'key' => 'wc_last_active',
|
||||
'type' => 'integer',
|
||||
'label' => 'WC last customer activity timestamp (Unix)',
|
||||
),
|
||||
array(
|
||||
// WooCommerce stores this per-site via Users::update_site_user_meta(),
|
||||
// which appends the de-suffixed blog prefix ('wc_order_count' + '_wp'
|
||||
// on the main site, '_wp_2' on a subsite). Compute it the same way
|
||||
// rather than hardcoding the main-site form.
|
||||
'key' => 'wc_order_count_' . rtrim( $GLOBALS['wpdb']->get_blog_prefix(), '_' ),
|
||||
'type' => 'integer',
|
||||
'label' => 'WC order count for the current site (blog-aware key)',
|
||||
),
|
||||
array(
|
||||
'key' => 'last_update',
|
||||
'type' => 'integer',
|
||||
'label' => 'WC customer last profile update timestamp (Unix)',
|
||||
),
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Subscription product fields (wc-subscriptions or wc-linepay-subscription).
|
||||
*
|
||||
* @param TMDO_Schema_Registry $schema Registry.
|
||||
* @return void
|
||||
*/
|
||||
private static function register_subscription_fields( $schema ): void {
|
||||
$schema->register_many(
|
||||
self::PROVIDER,
|
||||
array(
|
||||
array(
|
||||
'post_type' => 'product',
|
||||
'meta_key' => '_subscription_price',
|
||||
'zone' => 'hot',
|
||||
'data_type' => 'decimal(15,4) NULL',
|
||||
'column' => '_subscription_price',
|
||||
),
|
||||
array(
|
||||
'post_type' => 'product',
|
||||
'meta_key' => '_subscription_period',
|
||||
'zone' => 'hot',
|
||||
'data_type' => 'varchar(20) NULL',
|
||||
'column' => '_subscription_period',
|
||||
),
|
||||
array(
|
||||
'post_type' => 'product',
|
||||
'meta_key' => '_subscription_period_interval',
|
||||
'zone' => 'hot',
|
||||
'data_type' => 'int(11) NULL',
|
||||
'column' => '_subscription_period_interval',
|
||||
),
|
||||
array(
|
||||
'post_type' => 'product',
|
||||
'meta_key' => '_subscription_length',
|
||||
'zone' => 'hot',
|
||||
'data_type' => 'int(11) NULL',
|
||||
'column' => '_subscription_length',
|
||||
),
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Subscription extension detection — WC native or wc-linepay-subscription.
|
||||
*
|
||||
* @return bool
|
||||
*/
|
||||
private static function has_subscriptions(): bool {
|
||||
return class_exists( 'WC_Subscriptions' )
|
||||
|| defined( 'WCS_INIT_TIMESTAMP' )
|
||||
|| file_exists( WP_PLUGIN_DIR . '/wc-linepay-subscription/wc-linepay-subscription.php' );
|
||||
}
|
||||
|
||||
/**
|
||||
* WPDO doctor probe for any wc_* table — verifies existence + reports row count.
|
||||
*
|
||||
|
||||
@@ -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() );
|
||||
}
|
||||
}
|
||||
|
||||
@@ -230,4 +230,22 @@ class WooCommerceIntegrationTest extends TestCase {
|
||||
$pp->setValue( $registry, $original );
|
||||
}
|
||||
}
|
||||
|
||||
// ── 2.2 PR-085: WooCommerce data stays in native meta ───────────────────
|
||||
|
||||
/**
|
||||
* @runInSeparateProcess
|
||||
* @preserveGlobalState disabled
|
||||
*/
|
||||
public function test_register_keeps_woocommerce_meta_out_of_the_flat_tables(): void {
|
||||
eval( 'class WooCommerce {}' ); // phpcs:ignore Squiz.PHP.Eval -- WooCommerce stand-in.
|
||||
$GLOBALS['_wp_filter_callbacks'] = array();
|
||||
|
||||
TMDO_WooCommerce::register();
|
||||
|
||||
$hooks = $GLOBALS['_wp_filter_callbacks'];
|
||||
$this->assertArrayNotHasKey( 'wpdo_register_fields', $hooks, 'no product / order / subscription zone fields' );
|
||||
$this->assertArrayNotHasKey( 'wpdo_register_entity_fields', $hooks, 'no user hot group' );
|
||||
$this->assertArrayHasKey( 'wpdo_register_custom_tables', $hooks, 'WooCommerce custom tables stay registered' );
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user