check_requirements();
$this->includes();
$this->init_hooks();
}
/**
* Check plugin requirements
*/
private function check_requirements() {
// Check if WooCommerce is active
// `active_plugins` is a WordPress core filter being read, not a hook
// this plugin defines, so the plugin-prefix rule does not apply.
// phpcs:ignore WordPress.NamingConventions.PrefixAllGlobals.NonPrefixedHooknameFound -- reading a core filter
if (!in_array('woocommerce/woocommerce.php', (array) apply_filters('active_plugins', get_option('active_plugins')), true)) {
add_action('admin_notices', [$this, 'woocommerce_missing_notice']);
return;
}
}
/**
* WooCommerce missing notice
*/
public function woocommerce_missing_notice() {
?>
post_type !== 'product' && $post->post_type !== 'product_variation') {
return;
}
if ($this->product_mapper) {
$this->product_mapper->unmap($post_id);
}
}
/**
* Initialize plugin components
*/
public function init_components() {
// Check WooCommerce again
if (!class_exists('WooCommerce')) {
return;
}
// Initialize components
$this->logger = new Logger();
$this->api_client = new API_Client($this->logger);
$this->price_sync = new Price_Sync($this->api_client, $this->logger);
$this->stock_sync = new Stock_Sync($this->api_client, $this->logger);
$this->cron = new Cron($this->price_sync, $this->stock_sync, $this->logger);
$this->product_mapper = new Product_Mapper($this->logger);
$this->product_importer = new Product_Importer($this->api_client, $this->price_sync, $this->stock_sync, $this->product_mapper, $this->logger);
$this->order_handler = new Order_Handler($this->api_client, $this->product_mapper, $this->logger);
$this->auto_importer = new Auto_Importer($this->product_importer, $this->api_client, $this->logger);
// Ensure mapping table exists (for upgrades from older versions)
// Move any data still stored under the old `dsz_` prefix before
// anything tries to read it
$this->maybe_migrate_prefix();
$this->maybe_create_mapping_table();
// Register auto import cron hook
add_action('dszsync_auto_import_cron_hook', [$this, 'run_auto_import']);
// Daily maintenance (log retention, orphaned mappings, stale transients)
add_action('dszsync_maintenance_hook', [$this, 'run_maintenance']);
if (!wp_next_scheduled('dszsync_maintenance_hook')) {
wp_schedule_event(time() + DAY_IN_SECONDS, 'daily', 'dszsync_maintenance_hook');
}
// Tracking sync: poll DSZ twice daily for tracking/status on submitted orders
add_action('dszsync_tracking_sync_hook', [$this, 'run_tracking_sync']);
if (!wp_next_scheduled('dszsync_tracking_sync_hook')) {
wp_schedule_event(time() + HOUR_IN_SECONDS, 'twicedaily', 'dszsync_tracking_sync_hook');
}
// Auto-submit paid orders to DSZ when enabled (middleman flow)
add_action('woocommerce_order_status_processing', [$this, 'maybe_auto_submit_order'], 20);
if (is_admin()) {
$this->admin_ui = new Admin_UI($this->api_client, $this->price_sync, $this->stock_sync, $this->cron, $this->logger, $this->product_mapper, $this->product_importer, $this->order_handler, $this->auto_importer);
}
// Register shipping method (after WooCommerce shipping is initialized)
add_action('woocommerce_shipping_init', [$this, 'load_shipping_method']);
add_filter('woocommerce_shipping_methods', [$this, 'register_shipping_method']);
// One-time zone attachment after activation (deferred until WC is ready)
add_action('admin_init', [$this, 'maybe_setup_shipping_zones']);
}
/**
* Load the shipping method class after WooCommerce is ready
*/
public function load_shipping_method() {
require_once DSZSYNC_PLUGIN_DIR . 'includes/class-shipping-method.php';
}
/**
* Register DSZ shipping method with WooCommerce
*
* @param array $methods Existing shipping methods.
* @return array Modified shipping methods.
*/
public function register_shipping_method($methods) {
$methods['dszsync_shipping'] = 'Dropshipzone\\Shipping_Method';
return $methods;
}
/**
* Migrate data stored under the old `dsz_` prefix to `dszsync_`.
*
* The prefix was widened to satisfy the plugin directory's four-character
* minimum. Options, post meta, cron hooks and the shipping method id all
* moved; database table and column names did not, because they are scoped
* inside plugin-owned tables and cannot collide with anything.
*
* Runs once, gated by an option flag.
*/
private function maybe_migrate_prefix() {
if (get_option('dszsync_prefix_migrated')) {
return;
}
global $wpdb;
// Options: copy old value across, then drop the old row
$option_map = [
'dsz_sync_settings' => 'dszsync_settings',
'dsz_sync_price_rules' => 'dszsync_price_rules',
'dsz_sync_stock_rules' => 'dszsync_stock_rules',
'dsz_sync_import_settings' => 'dszsync_import_settings',
'dsz_sync_api_email' => 'dszsync_api_email',
'dsz_sync_api_password' => 'dszsync_api_password',
'dsz_sync_api_token' => 'dszsync_api_token',
'dsz_sync_token_expiry' => 'dszsync_token_expiry',
'dsz_auto_import_settings' => 'dszsync_auto_import_settings',
'dsz_auto_import_state' => 'dszsync_auto_import_state',
'dsz_auto_import_history' => 'dszsync_auto_import_history',
'dsz_rate_limit_data' => 'dszsync_rate_limit_data',
'dsz_order_settings' => 'dszsync_order_settings',
'dsz_import_templates' => 'dszsync_import_templates',
'dsz_price_rules_v2' => 'dszsync_price_rules_v2',
'dsz_log_retention_days' => 'dszsync_log_retention_days',
'dsz_mapping_schema_version' => 'dszsync_mapping_schema_version',
'dsz_setup_shipping_pending' => 'dszsync_setup_shipping_pending',
];
foreach ($option_map as $old => $new) {
$value = get_option($old, null);
if ($value !== null && get_option($new, null) === null) {
update_option($new, $value, false);
}
delete_option($old);
}
// Post meta
$meta_map = [
'_dsz_cost' => '_dszsync_cost',
'_dsz_serial_number' => '_dszsync_serial_number',
'_dsz_submitted_at' => '_dszsync_submitted_at',
'_dsz_not_available' => '_dszsync_not_available',
'_dsz_imported_image' => '_dszsync_imported_image',
'_dsz_tracking_number' => '_dszsync_tracking_number',
'_dsz_courier' => '_dszsync_courier',
];
foreach ($meta_map as $old => $new) {
// phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared, WordPress.DB.PreparedSQL.InterpolatedNotPrepared, WordPress.DB.PreparedSQLPlaceholders.UnfinishedPrepare, WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.DirectDatabaseQuery.SchemaChange, WordPress.DB.SlowDBQuery.slow_db_query_meta_key, PluginCheck.Security.DirectDB.UnescapedDBParameter -- Table name is built from $wpdb->prefix and is not user input; all values are passed through prepare(). These are plugin-owned tables, so no core caching API applies.
$wpdb->update($wpdb->postmeta, ['meta_key' => $new], ['meta_key' => $old], ['%s'], ['%s']);
}
// Order meta lives in its own table under HPOS
$hpos_meta = $wpdb->prefix . 'wc_orders_meta';
// phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared, WordPress.DB.PreparedSQL.InterpolatedNotPrepared, WordPress.DB.PreparedSQLPlaceholders.UnfinishedPrepare, WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.DirectDatabaseQuery.SchemaChange, WordPress.DB.SlowDBQuery.slow_db_query_meta_key, PluginCheck.Security.DirectDB.UnescapedDBParameter -- Table name is built from $wpdb->prefix and is not user input; all values are passed through prepare(). These are plugin-owned tables, so no core caching API applies.
if ($wpdb->get_var($wpdb->prepare('SHOW TABLES LIKE %s', $hpos_meta)) === $hpos_meta) {
foreach ($meta_map as $old => $new) {
// phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared, WordPress.DB.PreparedSQL.InterpolatedNotPrepared, WordPress.DB.PreparedSQLPlaceholders.UnfinishedPrepare, WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.DirectDatabaseQuery.SchemaChange, WordPress.DB.SlowDBQuery.slow_db_query_meta_key, PluginCheck.Security.DirectDB.UnescapedDBParameter -- Table name is built from $wpdb->prefix and is not user input; all values are passed through prepare(). These are plugin-owned tables, so no core caching API applies.
$wpdb->update($hpos_meta, ['meta_key' => $new], ['meta_key' => $old], ['%s'], ['%s']);
}
}
// Shipping method id, so configured zones keep working
$zone_methods = $wpdb->prefix . 'woocommerce_shipping_zone_methods';
// phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared, WordPress.DB.PreparedSQL.InterpolatedNotPrepared, WordPress.DB.PreparedSQLPlaceholders.UnfinishedPrepare, WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.DirectDatabaseQuery.SchemaChange, WordPress.DB.SlowDBQuery.slow_db_query_meta_key, PluginCheck.Security.DirectDB.UnescapedDBParameter -- Table name is built from $wpdb->prefix and is not user input; all values are passed through prepare(). These are plugin-owned tables, so no core caching API applies.
if ($wpdb->get_var($wpdb->prepare('SHOW TABLES LIKE %s', $zone_methods)) === $zone_methods) {
// phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared, WordPress.DB.PreparedSQL.InterpolatedNotPrepared, WordPress.DB.PreparedSQLPlaceholders.UnfinishedPrepare, WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.DirectDatabaseQuery.SchemaChange, WordPress.DB.SlowDBQuery.slow_db_query_meta_key, PluginCheck.Security.DirectDB.UnescapedDBParameter -- Table name is built from $wpdb->prefix and is not user input; all values are passed through prepare(). These are plugin-owned tables, so no core caching API applies.
$wpdb->update($zone_methods, ['method_id' => 'dszsync_shipping'], ['method_id' => 'dsz_shipping'], ['%s'], ['%s']);
}
// Per-instance shipping settings are stored as options named
// woocommerce___settings
// phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared, WordPress.DB.PreparedSQL.InterpolatedNotPrepared, WordPress.DB.PreparedSQLPlaceholders.UnfinishedPrepare, WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.DirectDatabaseQuery.SchemaChange, WordPress.DB.SlowDBQuery.slow_db_query_meta_key, PluginCheck.Security.DirectDB.UnescapedDBParameter -- Table name is built from $wpdb->prefix and is not user input; all values are passed through prepare(). These are plugin-owned tables, so no core caching API applies.
$shipping_options = $wpdb->get_col($wpdb->prepare(
"SELECT option_name FROM {$wpdb->options} WHERE option_name LIKE %s",
$wpdb->esc_like('woocommerce_dsz_shipping_') . '%'
));
foreach ((array) $shipping_options as $old_name) {
$new_name = str_replace('woocommerce_dsz_shipping_', 'woocommerce_dszsync_shipping_', $old_name);
$value = get_option($old_name, null);
if ($value !== null && get_option($new_name, null) === null) {
update_option($new_name, $value, false);
}
delete_option($old_name);
}
// Old cron hooks: clear them; the new ones are scheduled on init
foreach ([
'dsz_sync_cron_hook',
'dsz_auto_import_cron_hook',
'dsz_maintenance_hook',
'dsz_tracking_sync_hook',
'dsz_sync_batch_continue',
] as $old_hook) {
wp_clear_scheduled_hook($old_hook);
}
update_option('dszsync_prefix_migrated', 1, false);
}
/**
* Create mapping table if it doesn't exist and run migrations
*/
private function maybe_create_mapping_table() {
// Schema version gate — avoids an information_schema query on every load.
// Bump whenever the table structure changes.
// v3: composite index (sync_enabled, last_synced)
$schema_version = '3';
if (get_option('dszsync_mapping_schema_version') === $schema_version) {
return;
}
global $wpdb;
$table_name = $wpdb->prefix . 'dsz_product_mapping';
// Check if table exists
// phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared, WordPress.DB.PreparedSQL.InterpolatedNotPrepared, WordPress.DB.PreparedSQLPlaceholders.UnfinishedPrepare, WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.DirectDatabaseQuery.SchemaChange, WordPress.DB.SlowDBQuery.slow_db_query_meta_key, PluginCheck.Security.DirectDB.UnescapedDBParameter -- Table name is built from $wpdb->prefix and is not user input; all values are passed through prepare(). These are plugin-owned tables, so no core caching API applies.
$table_exists = $wpdb->get_var($wpdb->prepare(
"SELECT COUNT(1) FROM information_schema.tables WHERE table_schema = %s AND table_name = %s",
DB_NAME,
$table_name
));
if (!$table_exists) {
Product_Mapper::create_table();
} else {
// Run migrations for existing installations
Product_Mapper::maybe_add_last_resynced_column();
Product_Mapper::maybe_add_indexes();
}
update_option('dszsync_mapping_schema_version', $schema_version, false);
}
/**
* Plugin activation
*/
public function activate() {
// Create log table
$this->create_log_table();
// Create mapping table
Product_Mapper::create_table();
// Create orders table
Order_Handler::create_table();
// Create sync status option
$this->create_default_options();
// Schedule cron
if (!wp_next_scheduled('dszsync_cron_hook')) {
wp_schedule_event(time(), 'hourly', 'dszsync_cron_hook');
}
// Defer shipping zone setup to the next admin load — WooCommerce's
// shipping registry is not reliably available during activation
update_option('dszsync_setup_shipping_pending', 1, false);
// Flush rewrite rules
flush_rewrite_rules();
}
/**
* One-time setup: attach the DSZ shipping method to AU/NZ zones.
*
* Dropshipzone ships the products directly, so the store must quote
* their shipping rates — without a zone assignment the method never
* appears at checkout. Idempotent: skipped when any zone already has
* the method (merchants can still remove/re-arrange it manually).
*/
public function maybe_setup_shipping_zones() {
if (!get_option('dszsync_setup_shipping_pending')) {
return;
}
if (!class_exists('WC_Shipping_Zones') || !class_exists('WC_Shipping_Zone')) {
return; // WooCommerce not ready — retry next admin load
}
delete_option('dszsync_setup_shipping_pending');
$zones = \WC_Shipping_Zones::get_zones();
// Already attached somewhere? Respect the merchant's setup.
foreach ($zones as $zone_data) {
foreach ($zone_data['shipping_methods'] as $method) {
if ($method->id === 'dszsync_shipping') {
return;
}
}
}
$targets = [
'AU' => __('Australia', '3s-soft-price-stock-sync-for-dropshipzone'),
'NZ' => __('New Zealand', '3s-soft-price-stock-sync-for-dropshipzone'),
];
foreach ($targets as $country => $label) {
$target_zone = null;
// Reuse an existing zone that covers this country
foreach ($zones as $zone_data) {
foreach ($zone_data['zone_locations'] as $location) {
if ($location->type === 'country' && $location->code === $country) {
$target_zone = new \WC_Shipping_Zone($zone_data['id']);
break;
}
}
if ($target_zone) {
break;
}
}
if (!$target_zone) {
$target_zone = new \WC_Shipping_Zone();
$target_zone->set_zone_name($label);
$target_zone->add_location($country, 'country');
$target_zone->save();
}
$target_zone->add_shipping_method('dszsync_shipping');
if ($this->logger) {
$this->logger->info('DSZ shipping method attached to zone', [
'zone' => $target_zone->get_zone_name(),
'country' => $country,
]);
}
}
}
/**
* Plugin deactivation
*/
public function deactivate() {
// Clear scheduled cron
wp_clear_scheduled_hook('dszsync_cron_hook');
wp_clear_scheduled_hook('dszsync_auto_import_cron_hook');
wp_clear_scheduled_hook('dszsync_maintenance_hook');
wp_clear_scheduled_hook('dszsync_tracking_sync_hook');
wp_clear_scheduled_hook('dszsync_batch_continue');
wp_clear_scheduled_hook('dszsync_incremental_hook');
// Flush rewrite rules
flush_rewrite_rules();
}
/**
* Run auto import (cron callback)
*/
public function run_auto_import() {
if ($this->auto_importer) {
$this->auto_importer->run_import();
}
}
/**
* Tracking sync (cron callback)
*/
public function run_tracking_sync() {
if ($this->order_handler) {
$this->order_handler->sync_tracking();
}
}
/**
* Auto-submit a paid order to Dropshipzone when the option is enabled
*
* @param int $order_id WooCommerce order ID
*/
public function maybe_auto_submit_order($order_id) {
$settings = get_option('dszsync_order_settings', []);
if (empty($settings['auto_submit']) || !$this->order_handler) {
return;
}
if (!$this->order_handler->order_has_dszsync_products($order_id)) {
return;
}
$result = $this->order_handler->submit_order($order_id);
if (is_wp_error($result) && $result->get_error_code() !== 'already_submitted' && $this->logger) {
$this->logger->error('Auto-submit failed', [
'order_id' => $order_id,
'error' => $result->get_error_message(),
]);
}
}
/**
* Daily maintenance (cron callback)
*/
public function run_maintenance() {
global $wpdb;
// Log retention (days, filterable)
$retention = apply_filters('dszsync_log_retention_days', intval(get_option('dszsync_log_retention_days', 30)));
if ($this->logger) {
$this->logger->cleanup_older_than($retention);
}
// Mappings pointing at deleted products
if ($this->product_mapper) {
$this->product_mapper->cleanup_orphaned();
}
// Expired DSZ transients (zone/rate caches) that WP never got to reap
// phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared, WordPress.DB.PreparedSQL.InterpolatedNotPrepared, WordPress.DB.PreparedSQLPlaceholders.UnfinishedPrepare, WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.DirectDatabaseQuery.SchemaChange, WordPress.DB.SlowDBQuery.slow_db_query_meta_key, PluginCheck.Security.DirectDB.UnescapedDBParameter -- Table name is built from $wpdb->prefix and is not user input; all values are passed through prepare(). These are plugin-owned tables, so no core caching API applies.
$wpdb->query($wpdb->prepare(
"DELETE a, b FROM {$wpdb->options} a
INNER JOIN {$wpdb->options} b ON b.option_name = CONCAT('_transient_', SUBSTRING(a.option_name, 20))
WHERE a.option_name LIKE %s AND a.option_value < %d",
$wpdb->esc_like('_transient_timeout_dszsync_') . '%',
time()
));
if ($this->logger) {
$this->logger->debug('Maintenance run completed', ['log_retention_days' => $retention]);
}
}
/**
* Create log table
*/
private function create_log_table() {
global $wpdb;
$table_name = $wpdb->prefix . 'dsz_sync_logs';
$charset_collate = $wpdb->get_charset_collate();
$sql = "CREATE TABLE IF NOT EXISTS $table_name (
id bigint(20) unsigned NOT NULL AUTO_INCREMENT,
level varchar(20) NOT NULL DEFAULT 'info',
message text NOT NULL,
context longtext,
created_at datetime NOT NULL DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (id),
KEY level (level),
KEY created_at (created_at)
) $charset_collate;";
require_once ABSPATH . 'wp-admin/includes/upgrade.php';
dbDelta($sql);
}
/**
* Create default options
*/
private function create_default_options() {
// API Settings
if (!get_option('dszsync_api_email')) {
add_option('dszsync_api_email', '');
}
if (!get_option('dszsync_api_password')) {
add_option('dszsync_api_password', '');
}
if (!get_option('dszsync_api_token')) {
add_option('dszsync_api_token', '');
}
if (!get_option('dszsync_token_expiry')) {
add_option('dszsync_token_expiry', 0);
}
// Price Rules
$price_defaults = [
'markup_type' => 'percentage', // percentage or fixed
'markup_value' => 30,
'rounding_enabled' => true,
'rounding_type' => '99', // 99, 95, nearest
'gst_enabled' => true,
'gst_type' => 'include', // include or exclude
];
if (!get_option('dszsync_price_rules')) {
add_option('dszsync_price_rules', $price_defaults);
}
// Stock Rules
$stock_defaults = [
'buffer_enabled' => false,
'buffer_amount' => 0,
'zero_on_unavailable' => true,
'auto_out_of_stock' => true,
];
if (!get_option('dszsync_stock_rules')) {
add_option('dszsync_stock_rules', $stock_defaults);
}
// Sync Settings
$sync_defaults = [
'frequency' => 'hourly', // hourly, twicedaily, daily
'batch_size' => 100,
'last_sync' => null,
'sync_in_progress' => false,
'current_offset' => 0,
'products_updated' => 0,
'errors_count' => 0,
];
if (!get_option('dszsync_settings')) {
add_option('dszsync_settings', $sync_defaults);
}
}
/**
* Load plugin text domain
*/
public function load_textdomain() {
// load_plugin_textdomain('3s-soft-price-stock-sync-for-dropshipzone', false, dirname(DSZSYNC_PLUGIN_BASENAME) . '/languages');
}
/**
* Declare HPOS compatibility
*/
public function declare_hpos_compatibility() {
if (class_exists(\Automattic\WooCommerce\Utilities\FeaturesUtil::class)) {
\Automattic\WooCommerce\Utilities\FeaturesUtil::declare_compatibility('custom_order_tables', DSZSYNC_PLUGIN_FILE, true);
}
}
}
/**
* Initialize the plugin
*/
function dszsync_sync() {
return Dropshipzone_Sync::instance();
}
// Start the plugin
dszsync_sync();