commit e4a40e53a0e26fba9fc42e699e508173793e6b89 Author: kristianwind Date: Sun Aug 23 15:38:14 2026 +0200 Initial commit: WooCommerce Email Discount Manager plugin diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..437c3dc --- /dev/null +++ b/.gitignore @@ -0,0 +1,5 @@ +.DS_Store +node_modules/ +vendor/ +*.zip +wc-credentials.json diff --git a/README.txt b/README.txt new file mode 100644 index 0000000..3946212 --- /dev/null +++ b/README.txt @@ -0,0 +1,115 @@ +=== WooCommerce Email Discount Manager === +Contributors: wedsmp +Tags: woocommerce, discount, email, shipping, coupon +Requires at least: 5.8 +Tested up to: 6.7 +Requires PHP: 7.4 +Stable tag: 1.3.1 +License: GPLv2 or later +License URI: https://www.gnu.org/licenses/gpl-2.0.html + +Apply discounts and show/hide shipping methods based on the customer's email address. + +== Description == + +WooCommerce Email Discount Manager lets you give discounts and filter available +shipping methods based on the email address a customer logs in or checks out with. + += Discount Groups = + +* Create discount groups with a name and a discount percent. +* Attach any number of WooCommerce product categories to a group (drag & drop ordering). +* The group's discount percent applies to all products in the attached categories. + += Email Assignments = + +Assign an email pattern to exactly one of three discount types: + +* **Group** — the customer inherits a discount group's percent and categories. +* **Coupon** — an existing WooCommerce coupon is auto-applied at checkout. +* **Custom** — a custom percent applied to the entire cart. + +Email patterns can be: + +* A full email address: `john@example.com` +* A domain: `@company.com` (matches everyone on that domain) +* A partial match: `john` (matches any email containing "john") + +Matching priority: exact email first, then domain, then partial match. +Assignments can be toggled active/inactive and work for both logged-in users +and guests (via the checkout billing email field, tracked with AJAX). + +Every assignment can additionally grant **free shipping** — all shipping +methods become free at checkout for matching emails, regardless of the +assignment type. Coupon assignments pick from your existing WooCommerce +coupons in a dropdown. + += Shipping Rules = + +* Per discount group, choose which shipping methods are shown or hidden. +* No rules for a group means all shipping methods behave normally. +* If any method is set to "Show", only "Show" methods are offered (whitelist). +* Drag & drop reordering controls the order methods appear in at checkout. + += How discounts are applied = + +* Group and custom discounts are applied as negative fees on the cart. +* Coupons are applied via the standard WooCommerce coupon system. + +== Installation == + +1. Upload the `woocommerce-email-discount-manager` folder to `/wp-content/plugins/`. +2. Make sure WooCommerce is installed and active. +3. Activate the plugin through the Plugins screen in WordPress. +4. Go to WooCommerce → Email Discounts to configure groups, assignments and shipping rules. + += REST API = + +The plugin exposes a `wc-edm/v1` namespace for remote management (groups, +email assignments, shipping rules, audit log). Every route requires an +authenticated user with the `manage_woocommerce` capability — use a WordPress +Application Password over HTTPS. + +Requests are rate limited per user (or per IP when unauthenticated) and all +changes are written to the audit log. Both are adjustable with filters: + +* `wc_edm_rate_limit_enabled` (bool) +* `wc_edm_rate_limit_max_requests` (int, $method) +* `wc_edm_rate_limit_window` (int seconds, $method) +* `wc_edm_audit_log_enabled` (bool) +* `wc_edm_audit_log_retention_days` (int) +* `wc_edm_client_ip` (string) — override IP resolution behind a proxy/CDN + +== Frequently Asked Questions == + += Does it work for guests? = + +Yes. When a guest types their email in the checkout billing email field, the +plugin stores it in the WooCommerce session via AJAX and recalculates the cart, +so discounts and shipping rules apply immediately. + += What happens if several patterns match one email? = + +The most specific match wins: exact email, then domain, then partial match. + +== Changelog == + += 1.3.1 = +* Hardened loading: a class file that cannot be read is now recorded and reported in an admin notice instead of causing a fatal error. During a plugin upgrade WordPress replaces files one at a time, so a request landing mid-copy could previously take down wp-admin. +* Admin tabs are only shown when their controller loaded, so an incomplete install costs a tab rather than the whole screen. + += 1.3.0 = +* Added an audit log: every change made through the REST API is recorded with user, IP, endpoint and time, along with rejected and rate limited requests. Viewable under WooCommerce → Email Discounts → Audit Log, and via GET /wp-json/wc-edm/v1/audit-log. +* Added rate limiting on the REST API (default 60 reads and 20 writes per minute per user or IP), returning HTTP 429. Limits are filterable. +* Entries are pruned automatically after 30 days (filterable). + += 1.2.0 = +* Added a free shipping toggle on email assignments — matching customers get all shipping methods at zero cost, works with every assignment type. +* Coupon assignments now pick from the shop's existing WooCommerce coupons in a dropdown instead of a free-text field. +* Database schema upgrades automatically after plugin updates (no re-activation needed). + += 1.1.0 = +* Added REST API (namespace wc-edm/v1) for remote management of groups, email assignments and shipping rules. All routes require an authenticated user with the manage_woocommerce capability (use an Application Password over HTTPS). + += 1.0.0 = +* Initial release: discount groups, email assignments (group/coupon/custom), shipping rules, dashboard. diff --git a/assets/css/admin.css b/assets/css/admin.css new file mode 100644 index 0000000..41fa3bd --- /dev/null +++ b/assets/css/admin.css @@ -0,0 +1,236 @@ +/* WooCommerce Email Discount Manager — admin styles */ + +.wc-edm-wrap .wc-edm-content { + margin-top: 20px; +} + +/* Header row with title + primary action */ +.wc-edm-header-row { + display: flex; + align-items: center; + justify-content: space-between; + flex-wrap: wrap; + gap: 10px; + margin-bottom: 12px; +} + +.wc-edm-header-row h2 { + margin: 0; +} + +/* Dashboard stat cards */ +.wc-edm-stats { + display: flex; + flex-wrap: wrap; + gap: 16px; + margin: 8px 0 24px; +} + +.wc-edm-stat-card { + background: #fff; + border: 1px solid #c3c4c7; + border-radius: 4px; + box-shadow: 0 1px 1px rgba(0, 0, 0, 0.04); + padding: 20px 28px; + min-width: 180px; + display: flex; + flex-direction: column; + gap: 4px; +} + +.wc-edm-stat-number { + font-size: 32px; + font-weight: 600; + line-height: 1.2; + color: #1d2327; +} + +.wc-edm-stat-label { + color: #646970; + font-size: 13px; +} + +.wc-edm-quick-actions .button { + margin-right: 8px; +} + +/* Tables */ +.wc-edm-table { + max-width: 1100px; +} + +.wc-edm-table .wc-edm-actions-col { + width: 150px; + text-align: right; +} + +.wc-edm-table .wc-edm-actions-col .button { + margin-left: 4px; +} + +/* Badges */ +.wc-edm-badge { + display: inline-block; + padding: 2px 10px; + border-radius: 12px; + font-size: 12px; + font-weight: 600; + line-height: 1.6; + background: #f0f0f1; + color: #3c434a; +} + +.wc-edm-badge-percent, +.wc-edm-badge-group { + background: #e5f1e8; + color: #00450c; +} + +.wc-edm-badge-coupon { + background: #fcf0e4; + color: #8a4d00; +} + +.wc-edm-badge-custom { + background: #e6f0fa; + color: #0a4b78; +} + +.wc-edm-badge-denied { + background: #fcebeb; + color: #8a2424; +} + +/* Audit log */ +.wc-edm-audit-table code { + background: #f0f0f1; + padding: 1px 5px; + border-radius: 3px; + font-size: 12px; +} + +.wc-edm-audit-table .wc-edm-item-meta { + display: block; + color: #646970; + font-size: 12px; + margin-top: 2px; +} + +/* Active / inactive status */ +.wc-edm-status { + text-decoration: none; + font-weight: 600; +} + +.wc-edm-status.is-active { + color: #00a32a; +} + +.wc-edm-status.is-inactive { + color: #d63638; +} + +.wc-edm-status:hover { + text-decoration: underline; +} + +/* Sortable lists (categories + shipping methods) */ +.wc-edm-sortable { + margin: 12px 0 4px; + max-width: 700px; +} + +.wc-edm-sortable li { + display: flex; + align-items: center; + gap: 10px; + background: #fff; + border: 1px solid #c3c4c7; + border-radius: 4px; + padding: 8px 12px; + margin-bottom: 6px; + cursor: default; +} + +.wc-edm-sortable .wc-edm-drag-handle { + color: #787c82; + cursor: move; + flex: 0 0 auto; +} + +.wc-edm-sortable .wc-edm-item-label { + flex: 1 1 auto; +} + +.wc-edm-sortable .wc-edm-item-meta { + display: block; + color: #646970; + font-size: 12px; +} + +.wc-edm-sortable-placeholder { + border: 1px dashed #8c8f94; + border-radius: 4px; + background: #f6f7f7; + margin-bottom: 6px; + visibility: visible !important; +} + +/* Remove-category button */ +.wc-edm-remove-category { + flex: 0 0 auto; + border: none; + background: none; + color: #d63638; + font-size: 18px; + line-height: 1; + cursor: pointer; + padding: 0 4px; +} + +.wc-edm-remove-category:hover { + color: #8a2424; +} + +/* Category picker */ +.wc-edm-category-picker { + display: flex; + align-items: center; + gap: 8px; +} + +/* Shipping rules */ +.wc-edm-group-selector { + margin: 16px 0; + display: flex; + align-items: center; + gap: 8px; +} + +.wc-edm-shipping-list { + max-width: 900px; +} + +.wc-edm-rule-options { + flex: 0 0 auto; + display: flex; + gap: 16px; + white-space: nowrap; +} + +.wc-edm-rule-options label { + display: inline-flex; + align-items: center; + gap: 4px; +} + +@media screen and (max-width: 782px) { + .wc-edm-sortable li { + flex-wrap: wrap; + } + + .wc-edm-rule-options { + width: 100%; + padding-left: 30px; + } +} diff --git a/assets/js/admin.js b/assets/js/admin.js new file mode 100644 index 0000000..82c4f20 --- /dev/null +++ b/assets/js/admin.js @@ -0,0 +1,87 @@ +/** + * WooCommerce Email Discount Manager — admin scripts. + */ +( function( $ ) { + 'use strict'; + + $( function() { + + // --------------------------------------------------------------- + // Drag & drop sorting (categories + shipping methods). + // --------------------------------------------------------------- + $( '.wc-edm-sortable' ).sortable( { + handle: '.wc-edm-drag-handle', + axis: 'y', + placeholder: 'wc-edm-sortable-placeholder', + forcePlaceholderSize: true + } ); + + // --------------------------------------------------------------- + // Group edit: add / remove categories. + // --------------------------------------------------------------- + var $categorySelect = $( '#wc-edm-category-select' ); + var $categoryList = $( '#wc-edm-selected-categories' ); + + $( '#wc-edm-add-category' ).on( 'click', function() { + var $option = $categorySelect.find( 'option:selected' ); + var id = $option.val(); + var name = $option.text().trim(); + + if ( ! id || $option.prop( 'disabled' ) ) { + return; + } + + var $item = $( '
  • ' ) + .attr( 'data-id', id ) + .append( $( '' ).addClass( 'dashicons dashicons-menu wc-edm-drag-handle' ) ) + .append( $( '' ).addClass( 'wc-edm-item-label' ).text( name ) ) + .append( $( ' + + + +

    + + 'groups', 'message' => 'error' ) ) ); + exit; + } + + $data = array( + 'name' => $name, + 'discount_percent' => $percent, + ); + + if ( $id ) { + WC_EDM_DB::update_group( $id, $data ); + } else { + $id = WC_EDM_DB::insert_group( $data ); + } + + if ( $id ) { + WC_EDM_DB::set_group_categories( $id, $category_ids ); + } + + wp_safe_redirect( WC_EDM_Helpers::admin_url( array( 'tab' => 'groups', 'message' => $id ? 'saved' : 'error' ) ) ); + exit; + } + + // Delete. + if ( isset( $_GET['action'], $_GET['id'] ) && 'delete' === $_GET['action'] ) { + $id = absint( $_GET['id'] ); + check_admin_referer( 'wc_edm_delete_group_' . $id ); + + WC_EDM_DB::delete_group( $id ); + + wp_safe_redirect( WC_EDM_Helpers::admin_url( array( 'tab' => 'groups', 'message' => 'deleted' ) ) ); + exit; + } + } + + /** + * Render the groups tab (list or edit form). + */ + public function render() { + $action = isset( $_GET['action'] ) ? sanitize_key( wp_unslash( $_GET['action'] ) ) : ''; // phpcs:ignore WordPress.Security.NonceVerification.Recommended + + if ( 'new' === $action || 'edit' === $action ) { + $id = isset( $_GET['id'] ) ? absint( $_GET['id'] ) : 0; // phpcs:ignore WordPress.Security.NonceVerification.Recommended + $this->render_form( $id ); + return; + } + + $this->render_list(); + } + + /** + * Render the list of groups. + */ + private function render_list() { + $groups = WC_EDM_DB::get_groups(); + ?> +
    +

    + + + +
    + + +

    + + + + + + + + + + + + + + + id ); + $names = array(); + + foreach ( $category_ids as $category_id ) { + $term = get_term( $category_id, 'product_cat' ); + if ( $term && ! is_wp_error( $term ) ) { + $names[] = $term->name; + } + } + + $edit_url = WC_EDM_Helpers::admin_url( array( 'tab' => 'groups', 'action' => 'edit', 'id' => (int) $group->id ) ); + + $delete_url = wp_nonce_url( + WC_EDM_Helpers::admin_url( array( 'tab' => 'groups', 'action' => 'delete', 'id' => (int) $group->id ) ), + 'wc_edm_delete_group_' . (int) $group->id + ); + ?> + + + + + + + + +
    name ); ?>discount_percent ) ); ?>%' . esc_html__( 'None', 'wc-email-discount' ) . ''; ?> + + +
    + term_id ] = $category; + } + + if ( $id && ! $group ) { + echo '

    ' . esc_html__( 'Group not found.', 'wc-email-discount' ) . '

    '; + return; + } + ?> +

    + +
    + + + + + + + + + + + + + + + + + + + +

    + + + + +

    +
    + 'shipping', 'message' => 'error' ) ) ); + exit; + } + + $order = isset( $_POST['method_order'] ) && is_array( $_POST['method_order'] ) + ? array_map( 'sanitize_text_field', wp_unslash( $_POST['method_order'] ) ) + : array(); + + $raw_rules = isset( $_POST['rule'] ) && is_array( $_POST['rule'] ) + ? array_map( 'sanitize_text_field', wp_unslash( $_POST['rule'] ) ) + : array(); + + $known_methods = WC_EDM_Helpers::get_all_shipping_methods(); + + // Rules are replaced wholesale: only methods with an explicit show/hide choice are stored. + $rules = array(); + + foreach ( $order as $index => $method_id ) { + if ( ! isset( $known_methods[ $method_id ] ) ) { + continue; + } + + $action = isset( $raw_rules[ $method_id ] ) ? $raw_rules[ $method_id ] : ''; + + if ( ! in_array( $action, array( 'show', 'hide' ), true ) ) { + continue; + } + + $rules[] = array( + 'shipping_method_id' => $method_id, + 'action' => $action, + 'sort_order' => $index, + ); + } + + WC_EDM_DB::replace_shipping_rules( $group_id, $rules ); + + wp_safe_redirect( + WC_EDM_Helpers::admin_url( + array( + 'tab' => 'shipping', + 'group_id' => $group_id, + 'message' => 'saved', + ) + ) + ); + exit; + } + + /** + * Render the shipping rules tab. + */ + public function render() { + $groups = WC_EDM_DB::get_groups(); + ?> +

    +

    + +

    + + +

    + + + + +

    + + + + + +
    + + + + + +
    + + render_rules_form( $group_id ); + } + + /** + * Render the rules form for a selected group. + * + * @param int $group_id Group ID. + */ + private function render_rules_form( $group_id ) { + $methods = WC_EDM_Helpers::get_all_shipping_methods(); + $rules = WC_EDM_DB::get_shipping_rules( $group_id ); + + if ( empty( $methods ) ) { + echo '

    ' . esc_html__( 'No shipping methods found. Configure shipping zones in WooCommerce first.', 'wc-email-discount' ) . '

    '; + return; + } + + // Existing rules by method id, and the display order: ruled methods first (by sort_order), then the rest. + $rules_by_method = array(); + foreach ( $rules as $rule ) { + $rules_by_method[ $rule->shipping_method_id ] = $rule; + } + + $ordered_keys = array_merge( + array_values( array_intersect( array_keys( $rules_by_method ), array_keys( $methods ) ) ), + array_values( array_diff( array_keys( $methods ), array_keys( $rules_by_method ) ) ) + ); + ?> +
    + + + + +
      + + action : ''; + ?> +
    • + + + + · + + + + + + + +
    • + +
    +

    + +

    + +

    +
    + groups = class_exists( 'WC_EDM_Admin_Groups' ) ? new WC_EDM_Admin_Groups() : null; + $this->emails = class_exists( 'WC_EDM_Admin_Emails' ) ? new WC_EDM_Admin_Emails() : null; + $this->shipping = class_exists( 'WC_EDM_Admin_Shipping' ) ? new WC_EDM_Admin_Shipping() : null; + $this->audit = class_exists( 'WC_EDM_Admin_Audit' ) ? new WC_EDM_Admin_Audit() : null; + + add_action( 'admin_menu', array( $this, 'register_menu' ) ); + add_action( 'admin_init', array( $this, 'handle_actions' ) ); + add_action( 'admin_enqueue_scripts', array( $this, 'enqueue_assets' ) ); + } + + /** + * Register the submenu page under WooCommerce. + */ + public function register_menu() { + add_submenu_page( + 'woocommerce', + __( 'Email Discounts', 'wc-email-discount' ), + __( 'Email Discounts', 'wc-email-discount' ), + 'manage_woocommerce', + self::PAGE_SLUG, + array( $this, 'render_page' ) + ); + } + + /** + * Enqueue admin CSS/JS on the plugin page only. + * + * @param string $hook Current admin page hook. + */ + public function enqueue_assets( $hook ) { + if ( false === strpos( $hook, self::PAGE_SLUG ) ) { + return; + } + + wp_enqueue_style( + 'wc-edm-admin', + WC_EDM_PLUGIN_URL . 'assets/css/admin.css', + array(), + WC_EDM_VERSION + ); + + wp_enqueue_script( + 'wc-edm-admin', + WC_EDM_PLUGIN_URL . 'assets/js/admin.js', + array( 'jquery', 'jquery-ui-sortable' ), + WC_EDM_VERSION, + true + ); + + wp_localize_script( + 'wc-edm-admin', + 'wcEdmAdmin', + array( + 'confirmDelete' => __( 'Are you sure you want to delete this item? This cannot be undone.', 'wc-email-discount' ), + ) + ); + } + + /** + * Get the currently active tab. + * + * @return string + */ + public function current_tab() { + $tab = isset( $_GET['tab'] ) ? sanitize_key( wp_unslash( $_GET['tab'] ) ) : 'dashboard'; // phpcs:ignore WordPress.Security.NonceVerification.Recommended + $tabs = array_keys( $this->get_tabs() ); + + return in_array( $tab, $tabs, true ) ? $tab : 'dashboard'; + } + + /** + * Tab definitions. + * + * @return array + */ + private function get_tabs() { + $tabs = array( 'dashboard' => __( 'Dashboard', 'wc-email-discount' ) ); + + // Only offer tabs whose controller actually loaded. + if ( $this->groups ) { + $tabs['groups'] = __( 'Discount Groups', 'wc-email-discount' ); + } + + if ( $this->emails ) { + $tabs['emails'] = __( 'Email Assignments', 'wc-email-discount' ); + } + + if ( $this->shipping ) { + $tabs['shipping'] = __( 'Shipping Rules', 'wc-email-discount' ); + } + + if ( $this->audit ) { + $tabs['audit'] = __( 'Audit Log', 'wc-email-discount' ); + } + + return $tabs; + } + + /** + * Handle POST/GET actions before any output (allows redirects). + */ + public function handle_actions() { + if ( ! isset( $_GET['page'] ) || self::PAGE_SLUG !== $_GET['page'] ) { // phpcs:ignore WordPress.Security.NonceVerification.Recommended + return; + } + + if ( ! current_user_can( 'manage_woocommerce' ) ) { + return; + } + + switch ( $this->current_tab() ) { + case 'groups': + $this->groups->handle_actions(); + break; + case 'emails': + $this->emails->handle_actions(); + break; + case 'shipping': + $this->shipping->handle_actions(); + break; + case 'audit': + $this->audit->handle_actions(); + break; + } + } + + /** + * Render the admin page with tab navigation. + */ + public function render_page() { + if ( ! current_user_can( 'manage_woocommerce' ) ) { + wp_die( esc_html__( 'You do not have permission to access this page.', 'wc-email-discount' ) ); + } + + $current = $this->current_tab(); + ?> +
    +

    + + render_notices(); ?> + + + +
    + groups->render(); + break; + case 'emails': + $this->emails->render(); + break; + case 'shipping': + $this->shipping->render(); + break; + case 'audit': + $this->audit->render(); + break; + default: + $this->render_dashboard(); + break; + } + ?> +
    +
    + array( 'success', __( 'Saved successfully.', 'wc-email-discount' ) ), + 'deleted' => array( 'success', __( 'Deleted successfully.', 'wc-email-discount' ) ), + 'error' => array( 'error', __( 'Something went wrong. Please try again.', 'wc-email-discount' ) ), + ); + + if ( ! isset( $messages[ $message ] ) ) { + return; + } + + list( $type, $text ) = $messages[ $message ]; + + printf( + '

    %2$s

    ', + esc_attr( $type ), + esc_html( $text ) + ); + } + + /** + * Render the dashboard tab: stats cards, quick actions, recent groups. + */ + private function render_dashboard() { + $group_count = WC_EDM_DB::count_groups(); + $email_count = WC_EDM_DB::count_emails(); + $active_count = WC_EDM_DB::count_emails( true ); + $recent = WC_EDM_DB::get_recent_groups( 5 ); + ?> +
    +
    + + +
    +
    + + +
    +
    + + +
    +
    + +

    +

    + + + + + + + + + +

    + +

    + +

    + + + + + + + + + + + + + + + + + + + + +
    + + name ); ?> + + discount_percent ) ); ?>%id ) ) ); ?>created_at ) ) ); ?>
    + + $user ? (int) $user->ID : 0, + 'user_login' => $user && $user->user_login ? $user->user_login : '', + 'ip' => self::get_client_ip(), + 'method' => strtoupper( substr( (string) $method, 0, 10 ) ), + 'route' => substr( (string) $route, 0, 191 ), + 'status' => (int) $status, + 'outcome' => $outcome, + 'details' => ! empty( $details ) ? wp_json_encode( $details ) : null, + ) + ); + } + + /** + * Delete entries older than the retention period. + * + * @return void + */ + public static function prune() { + WC_EDM_DB::prune_audit_log( self::retention_days() ); + } +} diff --git a/includes/class-db.php b/includes/class-db.php new file mode 100644 index 0000000..474bfac --- /dev/null +++ b/includes/class-db.php @@ -0,0 +1,647 @@ +prefix . 'wc_edm_' . $name; + } + + /** + * Create/upgrade the custom tables via dbDelta(), then add foreign keys. + */ + public static function create_tables() { + global $wpdb; + + require_once ABSPATH . 'wp-admin/includes/upgrade.php'; + + $charset_collate = $wpdb->get_charset_collate(); + + $groups = self::table( 'groups' ); + $group_categories = self::table( 'group_categories' ); + $emails = self::table( 'emails' ); + $shipping_rules = self::table( 'shipping_rules' ); + $audit_log = self::table( 'audit_log' ); + + $sql = "CREATE TABLE {$groups} ( + id bigint(20) unsigned NOT NULL AUTO_INCREMENT, + name varchar(191) NOT NULL, + discount_percent decimal(5,2) NOT NULL DEFAULT 0.00, + created_at datetime NOT NULL, + updated_at datetime NOT NULL, + PRIMARY KEY (id) + ) {$charset_collate}; + CREATE TABLE {$group_categories} ( + id bigint(20) unsigned NOT NULL AUTO_INCREMENT, + group_id bigint(20) unsigned NOT NULL, + category_id bigint(20) unsigned NOT NULL, + sort_order int(11) NOT NULL DEFAULT 0, + PRIMARY KEY (id), + UNIQUE KEY group_category (group_id,category_id), + KEY group_id (group_id) + ) {$charset_collate}; + CREATE TABLE {$emails} ( + id bigint(20) unsigned NOT NULL AUTO_INCREMENT, + email_pattern varchar(191) NOT NULL, + assignment_type enum('group','coupon','custom') NOT NULL DEFAULT 'group', + group_id bigint(20) unsigned DEFAULT NULL, + coupon_code varchar(191) DEFAULT NULL, + custom_discount decimal(5,2) DEFAULT NULL, + is_active tinyint(1) NOT NULL DEFAULT 1, + free_shipping tinyint(1) NOT NULL DEFAULT 0, + created_at datetime NOT NULL, + updated_at datetime NOT NULL, + PRIMARY KEY (id), + KEY email_pattern (email_pattern), + KEY group_id (group_id) + ) {$charset_collate}; + CREATE TABLE {$shipping_rules} ( + id bigint(20) unsigned NOT NULL AUTO_INCREMENT, + group_id bigint(20) unsigned NOT NULL, + shipping_method_id varchar(191) NOT NULL, + action enum('show','hide') NOT NULL DEFAULT 'show', + sort_order int(11) NOT NULL DEFAULT 0, + PRIMARY KEY (id), + KEY group_id (group_id) + ) {$charset_collate}; + CREATE TABLE {$audit_log} ( + id bigint(20) unsigned NOT NULL AUTO_INCREMENT, + created_at datetime NOT NULL, + user_id bigint(20) unsigned NOT NULL DEFAULT 0, + user_login varchar(191) DEFAULT NULL, + ip varchar(45) DEFAULT NULL, + method varchar(10) NOT NULL DEFAULT '', + route varchar(191) NOT NULL DEFAULT '', + status smallint(5) unsigned NOT NULL DEFAULT 0, + outcome enum('allowed','denied','rate_limited') NOT NULL DEFAULT 'allowed', + details text DEFAULT NULL, + PRIMARY KEY (id), + KEY created_at (created_at), + KEY user_id (user_id), + KEY outcome (outcome) + ) {$charset_collate};"; + + dbDelta( $sql ); + + self::add_foreign_keys(); + + update_option( 'wc_edm_db_version', WC_EDM_VERSION ); + } + + /** + * Run create_tables() again when the plugin was updated without + * re-activation (dbDelta adds any missing columns). + */ + public static function maybe_upgrade() { + if ( get_option( 'wc_edm_db_version' ) !== WC_EDM_VERSION ) { + self::create_tables(); + } + } + + /** + * Add foreign key constraints (dbDelta cannot handle these). + */ + private static function add_foreign_keys() { + $groups = self::table( 'groups' ); + + self::maybe_add_foreign_key( + self::table( 'group_categories' ), + 'fk_wc_edm_group_categories_group', + "FOREIGN KEY (group_id) REFERENCES {$groups} (id) ON DELETE CASCADE" + ); + + self::maybe_add_foreign_key( + self::table( 'emails' ), + 'fk_wc_edm_emails_group', + "FOREIGN KEY (group_id) REFERENCES {$groups} (id) ON DELETE SET NULL" + ); + + self::maybe_add_foreign_key( + self::table( 'shipping_rules' ), + 'fk_wc_edm_shipping_rules_group', + "FOREIGN KEY (group_id) REFERENCES {$groups} (id) ON DELETE CASCADE" + ); + } + + /** + * Add a foreign key if it does not already exist. + * + * @param string $table Table name. + * @param string $constraint Constraint name. + * @param string $definition FOREIGN KEY definition SQL. + */ + private static function maybe_add_foreign_key( $table, $constraint, $definition ) { + global $wpdb; + + $exists = $wpdb->get_var( + $wpdb->prepare( + 'SELECT CONSTRAINT_NAME FROM information_schema.TABLE_CONSTRAINTS + WHERE CONSTRAINT_SCHEMA = %s AND TABLE_NAME = %s AND CONSTRAINT_NAME = %s AND CONSTRAINT_TYPE = %s', + DB_NAME, + $table, + $constraint, + 'FOREIGN KEY' + ) + ); + + if ( ! $exists ) { + // Table/constraint names are internal, not user input. + $wpdb->query( "ALTER TABLE {$table} ADD CONSTRAINT {$constraint} {$definition}" ); // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared + } + } + + /* + |-------------------------------------------------------------------------- + | Discount groups + |-------------------------------------------------------------------------- + */ + + /** + * Get all discount groups. + * + * @return array Array of row objects. + */ + public static function get_groups() { + global $wpdb; + $table = self::table( 'groups' ); + return $wpdb->get_results( "SELECT * FROM {$table} ORDER BY name ASC" ); // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared + } + + /** + * Count discount groups. + * + * @return int + */ + public static function count_groups() { + global $wpdb; + $table = self::table( 'groups' ); + return (int) $wpdb->get_var( "SELECT COUNT(*) FROM {$table}" ); // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared + } + + /** + * Get the most recently created groups. + * + * @param int $limit Number of groups. + * @return array + */ + public static function get_recent_groups( $limit = 5 ) { + global $wpdb; + $table = self::table( 'groups' ); + return $wpdb->get_results( + $wpdb->prepare( "SELECT * FROM {$table} ORDER BY created_at DESC LIMIT %d", $limit ) // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared + ); + } + + /** + * Get a single group. + * + * @param int $id Group ID. + * @return object|null + */ + public static function get_group( $id ) { + global $wpdb; + $table = self::table( 'groups' ); + return $wpdb->get_row( + $wpdb->prepare( "SELECT * FROM {$table} WHERE id = %d", $id ) // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared + ); + } + + /** + * Insert a group. + * + * @param array $data { name, discount_percent }. + * @return int New group ID (0 on failure). + */ + public static function insert_group( $data ) { + global $wpdb; + $now = current_time( 'mysql' ); + + $wpdb->insert( + self::table( 'groups' ), + array( + 'name' => $data['name'], + 'discount_percent' => $data['discount_percent'], + 'created_at' => $now, + 'updated_at' => $now, + ), + array( '%s', '%f', '%s', '%s' ) + ); + + return (int) $wpdb->insert_id; + } + + /** + * Update a group. + * + * @param int $id Group ID. + * @param array $data { name, discount_percent }. + * @return bool + */ + public static function update_group( $id, $data ) { + global $wpdb; + + return false !== $wpdb->update( + self::table( 'groups' ), + array( + 'name' => $data['name'], + 'discount_percent' => $data['discount_percent'], + 'updated_at' => current_time( 'mysql' ), + ), + array( 'id' => $id ), + array( '%s', '%f', '%s' ), + array( '%d' ) + ); + } + + /** + * Delete a group and its related rows. + * + * @param int $id Group ID. + * @return bool + */ + public static function delete_group( $id ) { + global $wpdb; + + // Manual cleanup in case foreign keys are unsupported (e.g. MyISAM). + $wpdb->delete( self::table( 'group_categories' ), array( 'group_id' => $id ), array( '%d' ) ); + $wpdb->delete( self::table( 'shipping_rules' ), array( 'group_id' => $id ), array( '%d' ) ); + $wpdb->update( self::table( 'emails' ), array( 'group_id' => null ), array( 'group_id' => $id ), array( '%d' ), array( '%d' ) ); + + return false !== $wpdb->delete( self::table( 'groups' ), array( 'id' => $id ), array( '%d' ) ); + } + + /* + |-------------------------------------------------------------------------- + | Group categories (M2M) + |-------------------------------------------------------------------------- + */ + + /** + * Get category IDs for a group, ordered by sort_order. + * + * @param int $group_id Group ID. + * @return int[] + */ + public static function get_group_category_ids( $group_id ) { + global $wpdb; + $table = self::table( 'group_categories' ); + + return array_map( + 'intval', + $wpdb->get_col( + $wpdb->prepare( "SELECT category_id FROM {$table} WHERE group_id = %d ORDER BY sort_order ASC, id ASC", $group_id ) // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared + ) + ); + } + + /** + * Replace the categories for a group. Sort order follows array order. + * + * @param int $group_id Group ID. + * @param int[] $category_ids Ordered category IDs. + */ + public static function set_group_categories( $group_id, $category_ids ) { + global $wpdb; + + $wpdb->delete( self::table( 'group_categories' ), array( 'group_id' => $group_id ), array( '%d' ) ); + + $category_ids = array_values( array_unique( array_map( 'intval', $category_ids ) ) ); + + foreach ( $category_ids as $index => $category_id ) { + if ( $category_id <= 0 ) { + continue; + } + $wpdb->insert( + self::table( 'group_categories' ), + array( + 'group_id' => $group_id, + 'category_id' => $category_id, + 'sort_order' => $index, + ), + array( '%d', '%d', '%d' ) + ); + } + } + + /* + |-------------------------------------------------------------------------- + | Email assignments + |-------------------------------------------------------------------------- + */ + + /** + * Get all email assignments. + * + * @return array + */ + public static function get_emails() { + global $wpdb; + $table = self::table( 'emails' ); + return $wpdb->get_results( "SELECT * FROM {$table} ORDER BY email_pattern ASC" ); // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared + } + + /** + * Get all active email assignments. + * + * @return array + */ + public static function get_active_emails() { + global $wpdb; + $table = self::table( 'emails' ); + return $wpdb->get_results( "SELECT * FROM {$table} WHERE is_active = 1" ); // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared + } + + /** + * Count email assignments. + * + * @param bool $active_only Count only active assignments. + * @return int + */ + public static function count_emails( $active_only = false ) { + global $wpdb; + $table = self::table( 'emails' ); + $sql = "SELECT COUNT(*) FROM {$table}"; + if ( $active_only ) { + $sql .= ' WHERE is_active = 1'; + } + return (int) $wpdb->get_var( $sql ); // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared + } + + /** + * Get a single email assignment. + * + * @param int $id Assignment ID. + * @return object|null + */ + public static function get_email( $id ) { + global $wpdb; + $table = self::table( 'emails' ); + return $wpdb->get_row( + $wpdb->prepare( "SELECT * FROM {$table} WHERE id = %d", $id ) // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared + ); + } + + /** + * Insert an email assignment. + * + * @param array $data Assignment data. + * @return int New ID (0 on failure). + */ + public static function insert_email( $data ) { + global $wpdb; + $now = current_time( 'mysql' ); + + $wpdb->insert( + self::table( 'emails' ), + array( + 'email_pattern' => $data['email_pattern'], + 'assignment_type' => $data['assignment_type'], + 'group_id' => $data['group_id'], + 'coupon_code' => $data['coupon_code'], + 'custom_discount' => $data['custom_discount'], + 'is_active' => $data['is_active'], + 'free_shipping' => $data['free_shipping'], + 'created_at' => $now, + 'updated_at' => $now, + ) + ); + + return (int) $wpdb->insert_id; + } + + /** + * Update an email assignment. + * + * @param int $id Assignment ID. + * @param array $data Assignment data. + * @return bool + */ + public static function update_email( $id, $data ) { + global $wpdb; + + return false !== $wpdb->update( + self::table( 'emails' ), + array( + 'email_pattern' => $data['email_pattern'], + 'assignment_type' => $data['assignment_type'], + 'group_id' => $data['group_id'], + 'coupon_code' => $data['coupon_code'], + 'custom_discount' => $data['custom_discount'], + 'is_active' => $data['is_active'], + 'free_shipping' => $data['free_shipping'], + 'updated_at' => current_time( 'mysql' ), + ), + array( 'id' => $id ) + ); + } + + /** + * Toggle an email assignment's active flag. + * + * @param int $id Assignment ID. + * @param int $is_active 1 or 0. + * @return bool + */ + public static function set_email_active( $id, $is_active ) { + global $wpdb; + + return false !== $wpdb->update( + self::table( 'emails' ), + array( + 'is_active' => $is_active ? 1 : 0, + 'updated_at' => current_time( 'mysql' ), + ), + array( 'id' => $id ), + array( '%d', '%s' ), + array( '%d' ) + ); + } + + /** + * Delete an email assignment. + * + * @param int $id Assignment ID. + * @return bool + */ + public static function delete_email( $id ) { + global $wpdb; + return false !== $wpdb->delete( self::table( 'emails' ), array( 'id' => $id ), array( '%d' ) ); + } + + /* + |-------------------------------------------------------------------------- + | Shipping rules + |-------------------------------------------------------------------------- + */ + + /** + * Get shipping rules for a group, ordered by sort_order. + * + * @param int $group_id Group ID. + * @return array + */ + public static function get_shipping_rules( $group_id ) { + global $wpdb; + $table = self::table( 'shipping_rules' ); + + return $wpdb->get_results( + $wpdb->prepare( "SELECT * FROM {$table} WHERE group_id = %d ORDER BY sort_order ASC, id ASC", $group_id ) // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared + ); + } + + /** + * Replace all shipping rules for a group. + * + * @param int $group_id Group ID. + * @param array $rules Array of { shipping_method_id, action, sort_order }. + */ + public static function replace_shipping_rules( $group_id, $rules ) { + global $wpdb; + + $wpdb->delete( self::table( 'shipping_rules' ), array( 'group_id' => $group_id ), array( '%d' ) ); + + foreach ( $rules as $rule ) { + if ( empty( $rule['shipping_method_id'] ) || ! in_array( $rule['action'], array( 'show', 'hide' ), true ) ) { + continue; + } + $wpdb->insert( + self::table( 'shipping_rules' ), + array( + 'group_id' => $group_id, + 'shipping_method_id' => $rule['shipping_method_id'], + 'action' => $rule['action'], + 'sort_order' => (int) $rule['sort_order'], + ), + array( '%d', '%s', '%s', '%d' ) + ); + } + } + + /* + |-------------------------------------------------------------------------- + | Audit log + |-------------------------------------------------------------------------- + */ + + /** + * Insert an audit log entry. + * + * @param array $data Entry data. + * @return int New entry ID (0 on failure). + */ + public static function insert_audit_entry( $data ) { + global $wpdb; + + $wpdb->insert( + self::table( 'audit_log' ), + array( + 'created_at' => current_time( 'mysql' ), + 'user_id' => (int) $data['user_id'], + 'user_login' => $data['user_login'], + 'ip' => $data['ip'], + 'method' => $data['method'], + 'route' => $data['route'], + 'status' => (int) $data['status'], + 'outcome' => $data['outcome'], + 'details' => $data['details'], + ), + array( '%s', '%d', '%s', '%s', '%s', '%s', '%d', '%s', '%s' ) + ); + + return (int) $wpdb->insert_id; + } + + /** + * Get audit log entries, newest first. + * + * @param int $limit Max entries. + * @param int $offset Offset. + * @param string $outcome Optional outcome filter (allowed/denied/rate_limited). + * @return array + */ + public static function get_audit_entries( $limit = 50, $offset = 0, $outcome = '' ) { + global $wpdb; + $table = self::table( 'audit_log' ); + + if ( in_array( $outcome, array( 'allowed', 'denied', 'rate_limited' ), true ) ) { + return $wpdb->get_results( + $wpdb->prepare( + "SELECT * FROM {$table} WHERE outcome = %s ORDER BY id DESC LIMIT %d OFFSET %d", // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared + $outcome, + $limit, + $offset + ) + ); + } + + return $wpdb->get_results( + $wpdb->prepare( "SELECT * FROM {$table} ORDER BY id DESC LIMIT %d OFFSET %d", $limit, $offset ) // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared + ); + } + + /** + * Count audit log entries. + * + * @param string $outcome Optional outcome filter. + * @return int + */ + public static function count_audit_entries( $outcome = '' ) { + global $wpdb; + $table = self::table( 'audit_log' ); + + if ( in_array( $outcome, array( 'allowed', 'denied', 'rate_limited' ), true ) ) { + return (int) $wpdb->get_var( + $wpdb->prepare( "SELECT COUNT(*) FROM {$table} WHERE outcome = %s", $outcome ) // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared + ); + } + + return (int) $wpdb->get_var( "SELECT COUNT(*) FROM {$table}" ); // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared + } + + /** + * Delete audit entries older than the retention period. + * + * @param int $days Retention in days. + * @return int Rows deleted. + */ + public static function prune_audit_log( $days ) { + global $wpdb; + $table = self::table( 'audit_log' ); + + $cutoff = gmdate( 'Y-m-d H:i:s', strtotime( current_time( 'mysql' ) . ' -' . absint( $days ) . ' days' ) ); + + return (int) $wpdb->query( + $wpdb->prepare( "DELETE FROM {$table} WHERE created_at < %s", $cutoff ) // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared + ); + } + + /** + * Delete every audit log entry. + * + * @return int Rows deleted. + */ + public static function clear_audit_log() { + global $wpdb; + $table = self::table( 'audit_log' ); + + return (int) $wpdb->query( "DELETE FROM {$table}" ); // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared + } +} diff --git a/includes/class-frontend.php b/includes/class-frontend.php new file mode 100644 index 0000000..a11f002 --- /dev/null +++ b/includes/class-frontend.php @@ -0,0 +1,413 @@ + admin_url( 'admin-ajax.php' ), + 'nonce' => wp_create_nonce( 'wc-edm-track-email' ), + ); + + $script = ' + jQuery( function( $ ) { + var wcEdm = ' . wp_json_encode( $params ) . '; + var timer = null; + var lastTracked = ""; + + function trackEmail( email ) { + if ( ! email || email.indexOf( "@" ) < 1 || email === lastTracked ) { + return; + } + lastTracked = email; + $.post( wcEdm.ajax_url, { + action: "wc_edm_track_email", + nonce: wcEdm.nonce, + email: email + }, function() { + $( document.body ).trigger( "update_checkout" ); + } ); + } + + $( document.body ).on( "keyup change blur", "#billing_email", function() { + var email = $( this ).val(); + clearTimeout( timer ); + timer = setTimeout( function() { + trackEmail( email ); + }, 600 ); + } ); + + // Track a prefilled email on page load. + var initial = $( "#billing_email" ).val(); + if ( initial ) { + trackEmail( initial ); + } + } );'; + + wp_add_inline_script( 'jquery', $script ); + } + + /** + * AJAX: store the checkout email in the WooCommerce session so + * discounts apply when totals are recalculated. + */ + public function ajax_track_email() { + check_ajax_referer( 'wc-edm-track-email', 'nonce' ); + + $email = isset( $_POST['email'] ) ? sanitize_email( wp_unslash( $_POST['email'] ) ) : ''; + + if ( ! WC()->session ) { + wp_send_json_error( array( 'message' => 'No session.' ) ); + } + + if ( $email && is_email( $email ) ) { + WC()->session->set( self::SESSION_EMAIL_KEY, $email ); + } else { + WC()->session->set( self::SESSION_EMAIL_KEY, null ); + } + + $this->clear_shipping_cache(); + + $match = $email ? WC_EDM_Helpers::find_email_match( $email ) : null; + + wp_send_json_success( + array( + 'matched' => (bool) $match, + ) + ); + } + + /** + * Clear cached shipping rates so shipping rules re-evaluate. + */ + private function clear_shipping_cache() { + if ( ! WC()->cart || ! WC()->session ) { + return; + } + + $packages = WC()->cart->get_shipping_packages(); + + foreach ( array_keys( $packages ) as $index ) { + WC()->session->set( 'shipping_for_package_' . $index, null ); + } + } + + /** + * Resolve the email address of the current customer. + * + * Logged-in users use their account email; guests use the tracked + * session email or the billing email WooCommerce already knows. + * + * @return string + */ + public function get_customer_email() { + if ( is_user_logged_in() ) { + $user = wp_get_current_user(); + if ( $user && $user->user_email ) { + return $user->user_email; + } + } + + if ( WC()->session ) { + $session_email = WC()->session->get( self::SESSION_EMAIL_KEY ); + if ( $session_email ) { + return $session_email; + } + } + + if ( WC()->customer ) { + $billing_email = WC()->customer->get_billing_email(); + if ( $billing_email ) { + return $billing_email; + } + } + + return ''; + } + + /** + * Get the current customer's email assignment match, if any. + * + * @return object|null + */ + private function get_current_match() { + $email = $this->get_customer_email(); + + if ( ! $email ) { + return null; + } + + return WC_EDM_Helpers::find_email_match( $email ); + } + + /** + * Apply group/custom discounts as negative fees. + * + * @param WC_Cart $cart Cart object. + */ + public function apply_discount_fees( $cart ) { + if ( is_admin() && ! defined( 'DOING_AJAX' ) ) { + return; + } + + $match = $this->get_current_match(); + + if ( ! $match ) { + return; + } + + if ( 'group' === $match->assignment_type ) { + $this->apply_group_fee( $cart, $match ); + } elseif ( 'custom' === $match->assignment_type ) { + $this->apply_custom_fee( $cart, $match ); + } + } + + /** + * Group discount: percentage off cart items in the group's categories. + * + * @param WC_Cart $cart Cart object. + * @param object $match Email assignment row. + */ + private function apply_group_fee( $cart, $match ) { + if ( ! $match->group_id ) { + return; + } + + $group = WC_EDM_DB::get_group( (int) $match->group_id ); + + if ( ! $group || (float) $group->discount_percent <= 0 ) { + return; + } + + $category_ids = WC_EDM_DB::get_group_category_ids( (int) $group->id ); + + if ( empty( $category_ids ) ) { + return; + } + + $eligible_total = 0; + + foreach ( $cart->get_cart() as $cart_item ) { + $product_id = (int) $cart_item['product_id']; // Parent ID for variations. + + if ( has_term( $category_ids, 'product_cat', $product_id ) ) { + $eligible_total += (float) $cart_item['line_total']; + } + } + + if ( $eligible_total <= 0 ) { + return; + } + + $discount = $eligible_total * ( (float) $group->discount_percent / 100 ); + + $cart->add_fee( + /* translators: %s: discount group name */ + sprintf( __( 'Email discount: %s', 'wc-email-discount' ), $group->name ), + -$discount, + false + ); + } + + /** + * Custom discount: percentage off the entire cart contents. + * + * @param WC_Cart $cart Cart object. + * @param object $match Email assignment row. + */ + private function apply_custom_fee( $cart, $match ) { + $percent = (float) $match->custom_discount; + + if ( $percent <= 0 ) { + return; + } + + $cart_total = 0; + + foreach ( $cart->get_cart() as $cart_item ) { + $cart_total += (float) $cart_item['line_total']; + } + + if ( $cart_total <= 0 ) { + return; + } + + $discount = $cart_total * ( $percent / 100 ); + + $cart->add_fee( + /* translators: %s: discount percent */ + sprintf( __( 'Email discount (%s%%)', 'wc-email-discount' ), wc_format_localized_decimal( $percent ) ), + -$discount, + false + ); + } + + /** + * Auto-apply a coupon for coupon-type assignments. + * + * @param WC_Cart $cart Cart object. + */ + public function maybe_apply_coupon( $cart ) { + if ( $this->coupon_applied || ( is_admin() && ! defined( 'DOING_AJAX' ) ) ) { + return; + } + + $match = $this->get_current_match(); + + if ( ! $match || 'coupon' !== $match->assignment_type || empty( $match->coupon_code ) ) { + return; + } + + $code = wc_format_coupon_code( $match->coupon_code ); + + if ( ! wc_get_coupon_id_by_code( $code ) ) { + return; + } + + if ( $cart->has_discount( $code ) ) { + return; + } + + $this->coupon_applied = true; + $cart->apply_coupon( $code ); + } + + /** + * Filter shipping rates for the matched email assignment. + * + * Group assignments get their show/hide rules applied; any assignment + * with the free shipping flag gets all remaining rates at zero cost. + * + * @param array $rates Available shipping rates (rate_id => WC_Shipping_Rate). + * @param array $package Shipping package. + * @return array + */ + public function filter_shipping_rates( $rates, $package ) { + $match = $this->get_current_match(); + + if ( ! $match ) { + return $rates; + } + + if ( 'group' === $match->assignment_type && $match->group_id ) { + $rates = $this->apply_group_shipping_rules( $rates, (int) $match->group_id ); + } + + if ( ! empty( $match->free_shipping ) ) { + foreach ( $rates as $rate ) { + $rate->set_cost( 0 ); + $rate->set_taxes( array() ); + } + } + + return $rates; + } + + /** + * Apply a group's show/hide shipping rules. + * + * If the group has no rules, all rates pass through untouched. + * "show" rules act as a whitelist; "hide" rules remove rates. + * Rates are reordered to follow the rules' sort order. + * + * @param array $rates Available shipping rates (rate_id => WC_Shipping_Rate). + * @param int $group_id Discount group ID. + * @return array + */ + private function apply_group_shipping_rules( $rates, $group_id ) { + $rules = WC_EDM_DB::get_shipping_rules( $group_id ); + + if ( empty( $rules ) ) { + return $rates; + } + + $show = array(); + $hide = array(); + + foreach ( $rules as $rule ) { + if ( 'show' === $rule->action ) { + $show[] = $rule->shipping_method_id; + } else { + $hide[] = $rule->shipping_method_id; + } + } + + $filtered = array(); + + foreach ( $rates as $rate_id => $rate ) { + // Whitelist mode: only explicitly shown methods survive. + if ( ! empty( $show ) && ! in_array( $rate_id, $show, true ) ) { + continue; + } + + if ( in_array( $rate_id, $hide, true ) ) { + continue; + } + + $filtered[ $rate_id ] = $rate; + } + + // Never leave the customer without shipping because of misconfigured rules. + if ( empty( $filtered ) ) { + return $rates; + } + + // Reorder rates to follow the rules' sort order; unmatched rates keep their position at the end. + $rule_order = wp_list_pluck( $rules, 'shipping_method_id' ); + $ordered = array(); + + foreach ( $rule_order as $method_id ) { + if ( isset( $filtered[ $method_id ] ) ) { + $ordered[ $method_id ] = $filtered[ $method_id ]; + unset( $filtered[ $method_id ] ); + } + } + + return $ordered + $filtered; + } +} diff --git a/includes/class-helpers.php b/includes/class-helpers.php new file mode 100644 index 0000000..ea2657d --- /dev/null +++ b/includes/class-helpers.php @@ -0,0 +1,224 @@ +email_pattern ) ); + + if ( '' === $pattern ) { + continue; + } + + // 1. Exact match wins immediately. + if ( $pattern === $email ) { + return $assignment; + } + + // 2. Domain match: pattern starts with '@'. + if ( null === $domain_match && 0 === strpos( $pattern, '@' ) ) { + if ( substr( $email, -strlen( $pattern ) ) === $pattern ) { + $domain_match = $assignment; + } + continue; + } + + // 3. Partial match: pattern occurs anywhere in the email. + if ( null === $partial_match && false === strpos( $pattern, '@' ) && false !== strpos( $email, $pattern ) ) { + $partial_match = $assignment; + } + } + + if ( $domain_match ) { + return $domain_match; + } + + return $partial_match; + } + + /** + * Get all product categories. + * + * @return WP_Term[] + */ + public static function get_product_categories() { + $terms = get_terms( + array( + 'taxonomy' => 'product_cat', + 'hide_empty' => false, + 'orderby' => 'name', + 'order' => 'ASC', + ) + ); + + return is_wp_error( $terms ) ? array() : $terms; + } + + /** + * Get every shipping method instance across all zones (including "Rest of the world"). + * + * @return array Map of method key ("flat_rate:3") => array{ label, zone, method }. + */ + public static function get_all_shipping_methods() { + $methods = array(); + + if ( ! class_exists( 'WC_Shipping_Zones' ) ) { + return $methods; + } + + $zones = WC_Shipping_Zones::get_zones(); + + // Zone 0 = "Locations not covered by your other zones". + $rest_of_world = new WC_Shipping_Zone( 0 ); + $zones[] = array( + 'zone_name' => $rest_of_world->get_zone_name(), + 'shipping_methods' => $rest_of_world->get_shipping_methods(), + ); + + foreach ( $zones as $zone ) { + $zone_name = $zone['zone_name']; + + foreach ( $zone['shipping_methods'] as $method ) { + $key = $method->id . ':' . $method->get_instance_id(); + + $methods[ $key ] = array( + 'key' => $key, + 'label' => $method->get_title(), + 'zone' => $zone_name, + 'method' => $method->id, + ); + } + } + + return $methods; + } + + /** + * Get all published WooCommerce coupon codes. + * + * @return string[] + */ + public static function get_coupon_codes() { + $posts = get_posts( + array( + 'post_type' => 'shop_coupon', + 'post_status' => 'publish', + 'posts_per_page' => 500, + 'orderby' => 'title', + 'order' => 'ASC', + ) + ); + + $codes = array(); + + foreach ( $posts as $post ) { + $codes[] = wc_format_coupon_code( $post->post_title ); + } + + return array_values( array_unique( $codes ) ); + } + + /** + * Get a human-readable description of an email assignment's target. + * + * @param object $assignment Row from the emails table. + * @return string + */ + public static function describe_assignment( $assignment ) { + $extra = ! empty( $assignment->free_shipping ) + ? ' + ' . __( 'free shipping', 'wc-email-discount' ) + : ''; + + return self::describe_assignment_type( $assignment ) . $extra; + } + + /** + * Describe the discount part of an assignment (without extras). + * + * @param object $assignment Row from the emails table. + * @return string + */ + private static function describe_assignment_type( $assignment ) { + switch ( $assignment->assignment_type ) { + case 'group': + $group = $assignment->group_id ? WC_EDM_DB::get_group( (int) $assignment->group_id ) : null; + if ( $group ) { + /* translators: 1: group name, 2: discount percent */ + return sprintf( __( '%1$s (%2$s%%)', 'wc-email-discount' ), $group->name, wc_format_localized_decimal( $group->discount_percent ) ); + } + return __( 'Group missing', 'wc-email-discount' ); + + case 'coupon': + /* translators: %s: coupon code */ + return sprintf( __( 'Coupon: %s', 'wc-email-discount' ), $assignment->coupon_code ); + + case 'custom': + /* translators: %s: discount percent */ + return sprintf( __( '%s%% off entire cart', 'wc-email-discount' ), wc_format_localized_decimal( $assignment->custom_discount ) ); + } + + return ''; + } + + /** + * Sanitize a discount percent value into the 0–100 range. + * + * @param mixed $value Raw value. + * @return float + */ + public static function sanitize_percent( $value ) { + $value = (float) wc_format_decimal( wp_unslash( $value ) ); + return max( 0, min( 100, $value ) ); + } + + /** + * Build an admin URL for the plugin page. + * + * @param array $args Extra query args (tab, action, id, ...). + * @return string + */ + public static function admin_url( $args = array() ) { + return add_query_arg( + array_merge( array( 'page' => 'wc-email-discounts' ), $args ), + admin_url( 'admin.php' ) + ); + } +} diff --git a/includes/class-rate-limiter.php b/includes/class-rate-limiter.php new file mode 100644 index 0000000..a2c4de1 --- /dev/null +++ b/includes/class-rate-limiter.php @@ -0,0 +1,162 @@ + false, + 'count' => 0, + 'max' => $max, + 'retry_after' => 0, + ); + } + + // The key carries the window slice, so each window gets its own + // counter and expires on its own. Re-arming a single transient's TTL + // on every request would instead extend the block indefinitely. + $now = time(); + $slice = (int) floor( $now / $window ); + $retry_after = ( ( $slice + 1 ) * $window ) - $now; + $bucket = self::is_write_method( $method ) ? 'w' : 'r'; + $key = self::PREFIX . $bucket . '_' . self::identity() . '_' . $slice; + + $count = (int) get_transient( $key ) + 1; + + set_transient( $key, $count, $window ); + + return array( + 'limited' => $count > $max, + 'count' => $count, + 'max' => $max, + 'retry_after' => max( 1, $retry_after ), + ); + } +} diff --git a/includes/class-rest-api.php b/includes/class-rest-api.php new file mode 100644 index 0000000..a45dbc7 --- /dev/null +++ b/includes/class-rest-api.php @@ -0,0 +1,796 @@ +get_method() : 'GET'; + $rate = WC_EDM_Rate_Limiter::check( $method ); + + if ( $rate['limited'] ) { + return new WP_Error( + 'wc_edm_rate_limited', + __( 'Too many requests. Please slow down.', 'wc-email-discount' ), + array( + 'status' => 429, + 'retry_after' => $rate['retry_after'], + ) + ); + } + + if ( current_user_can( 'manage_woocommerce' ) ) { + return true; + } + + return new WP_Error( + 'wc_edm_forbidden', + __( 'You are not allowed to manage email discounts.', 'wc-email-discount' ), + array( 'status' => rest_authorization_required_code() ) + ); + } + + /** + * Write an audit entry for wc-edm requests. + * + * Runs after the route callback (and also when a permission check failed), + * so both successful writes and rejected attempts are recorded. Successful + * reads are skipped to keep the log signal-heavy. + * + * @param WP_REST_Response|WP_HTTP_Response|WP_Error $response Result to send. + * @param array $handler Route handler. + * @param WP_REST_Request $request Current request. + * @return mixed The unmodified response. + */ + public function log_request( $response, $handler, $request ) { + if ( ! $request instanceof WP_REST_Request ) { + return $response; + } + + $route = $request->get_route(); + + if ( 0 !== strpos( ltrim( $route, '/' ), self::REST_NAMESPACE ) ) { + return $response; + } + + $method = $request->get_method(); + $is_write = WC_EDM_Rate_Limiter::is_write_method( $method ); + $details = array(); + $outcome = 'allowed'; + $status = 200; + + if ( is_wp_error( $response ) ) { + $error_data = $response->get_error_data(); + $status = is_array( $error_data ) && isset( $error_data['status'] ) ? (int) $error_data['status'] : 500; + $outcome = 'wc_edm_rate_limited' === $response->get_error_code() ? 'rate_limited' : 'denied'; + + $details['code'] = $response->get_error_code(); + } elseif ( $response instanceof WP_HTTP_Response ) { + $status = $response->get_status(); + + if ( $status >= 400 ) { + $outcome = 'denied'; + $data = $response->get_data(); + $details['code'] = is_array( $data ) && isset( $data['code'] ) ? $data['code'] : 'error'; + } + } + + // Successful reads are noise; everything else is worth keeping. + if ( 'allowed' === $outcome && ! $is_write ) { + return $response; + } + + // Don't let a blocked caller flood the log with identical rows. + if ( 'rate_limited' === $outcome && ! WC_EDM_Rate_Limiter::should_log_limit_hit() ) { + return $response; + } + + WC_EDM_Audit_Log::record( $outcome, $method, $route, $status, $details ); + + return $response; + } + + /** + * Register all routes. + */ + public function register_routes() { + $perm = array( $this, 'permission_check' ); + + register_rest_route( + self::REST_NAMESPACE, + '/status', + array( + 'methods' => WP_REST_Server::READABLE, + 'callback' => array( $this, 'get_status' ), + 'permission_callback' => $perm, + ) + ); + + register_rest_route( + self::REST_NAMESPACE, + '/groups', + array( + array( + 'methods' => WP_REST_Server::READABLE, + 'callback' => array( $this, 'get_groups' ), + 'permission_callback' => $perm, + ), + array( + 'methods' => WP_REST_Server::CREATABLE, + 'callback' => array( $this, 'create_group' ), + 'permission_callback' => $perm, + ), + ) + ); + + register_rest_route( + self::REST_NAMESPACE, + '/groups/(?P\d+)', + array( + array( + 'methods' => WP_REST_Server::READABLE, + 'callback' => array( $this, 'get_group' ), + 'permission_callback' => $perm, + ), + array( + 'methods' => WP_REST_Server::EDITABLE, + 'callback' => array( $this, 'update_group' ), + 'permission_callback' => $perm, + ), + array( + 'methods' => WP_REST_Server::DELETABLE, + 'callback' => array( $this, 'delete_group' ), + 'permission_callback' => $perm, + ), + ) + ); + + register_rest_route( + self::REST_NAMESPACE, + '/emails', + array( + array( + 'methods' => WP_REST_Server::READABLE, + 'callback' => array( $this, 'get_emails' ), + 'permission_callback' => $perm, + ), + array( + 'methods' => WP_REST_Server::CREATABLE, + 'callback' => array( $this, 'create_email' ), + 'permission_callback' => $perm, + ), + ) + ); + + register_rest_route( + self::REST_NAMESPACE, + '/emails/(?P\d+)', + array( + array( + 'methods' => WP_REST_Server::READABLE, + 'callback' => array( $this, 'get_email' ), + 'permission_callback' => $perm, + ), + array( + 'methods' => WP_REST_Server::EDITABLE, + 'callback' => array( $this, 'update_email' ), + 'permission_callback' => $perm, + ), + array( + 'methods' => WP_REST_Server::DELETABLE, + 'callback' => array( $this, 'delete_email' ), + 'permission_callback' => $perm, + ), + ) + ); + + register_rest_route( + self::REST_NAMESPACE, + '/shipping-methods', + array( + 'methods' => WP_REST_Server::READABLE, + 'callback' => array( $this, 'get_shipping_methods' ), + 'permission_callback' => $perm, + ) + ); + + register_rest_route( + self::REST_NAMESPACE, + '/groups/(?P\d+)/shipping-rules', + array( + array( + 'methods' => WP_REST_Server::READABLE, + 'callback' => array( $this, 'get_shipping_rules' ), + 'permission_callback' => $perm, + ), + array( + 'methods' => WP_REST_Server::EDITABLE, + 'callback' => array( $this, 'update_shipping_rules' ), + 'permission_callback' => $perm, + ), + ) + ); + + register_rest_route( + self::REST_NAMESPACE, + '/audit-log', + array( + 'methods' => WP_REST_Server::READABLE, + 'callback' => array( $this, 'get_audit_log' ), + 'permission_callback' => $perm, + 'args' => array( + 'limit' => array( + 'type' => 'integer', + 'default' => 50, + ), + 'offset' => array( + 'type' => 'integer', + 'default' => 0, + ), + 'outcome' => array( + 'type' => 'string', + 'enum' => array( 'allowed', 'denied', 'rate_limited' ), + ), + ), + ) + ); + + register_rest_route( + self::REST_NAMESPACE, + '/match', + array( + 'methods' => WP_REST_Server::READABLE, + 'callback' => array( $this, 'match_email' ), + 'permission_callback' => $perm, + 'args' => array( + 'email' => array( + 'required' => true, + 'type' => 'string', + ), + ), + ) + ); + } + + /* + |-------------------------------------------------------------------------- + | Formatters + |-------------------------------------------------------------------------- + */ + + /** + * Format a group row for API output. + * + * @param object $group Group row. + * @return array + */ + private function format_group( $group ) { + return array( + 'id' => (int) $group->id, + 'name' => $group->name, + 'discount_percent' => (float) $group->discount_percent, + 'category_ids' => WC_EDM_DB::get_group_category_ids( (int) $group->id ), + 'created_at' => $group->created_at, + 'updated_at' => $group->updated_at, + ); + } + + /** + * Format an email assignment row for API output. + * + * @param object $assignment Email row. + * @return array + */ + private function format_email( $assignment ) { + return array( + 'id' => (int) $assignment->id, + 'email_pattern' => $assignment->email_pattern, + 'assignment_type' => $assignment->assignment_type, + 'group_id' => $assignment->group_id ? (int) $assignment->group_id : null, + 'coupon_code' => $assignment->coupon_code, + 'custom_discount' => null !== $assignment->custom_discount ? (float) $assignment->custom_discount : null, + 'is_active' => (bool) $assignment->is_active, + 'free_shipping' => ! empty( $assignment->free_shipping ), + 'created_at' => $assignment->created_at, + 'updated_at' => $assignment->updated_at, + ); + } + + /** + * Standard 404 error. + * + * @return WP_Error + */ + private function not_found() { + return new WP_Error( 'wc_edm_not_found', __( 'Resource not found.', 'wc-email-discount' ), array( 'status' => 404 ) ); + } + + /* + |-------------------------------------------------------------------------- + | Status + |-------------------------------------------------------------------------- + */ + + /** + * GET /status — connectivity test plus quick stats. + * + * @return WP_REST_Response + */ + public function get_status() { + return rest_ensure_response( + array( + 'plugin' => 'woocommerce-email-discount-manager', + 'version' => WC_EDM_VERSION, + 'woocommerce' => defined( 'WC_VERSION' ) ? WC_VERSION : null, + 'groups' => WC_EDM_DB::count_groups(), + 'email_assignments' => WC_EDM_DB::count_emails(), + 'active_assignments' => WC_EDM_DB::count_emails( true ), + ) + ); + } + + /* + |-------------------------------------------------------------------------- + | Groups + |-------------------------------------------------------------------------- + */ + + /** + * GET /groups + * + * @return WP_REST_Response + */ + public function get_groups() { + return rest_ensure_response( array_map( array( $this, 'format_group' ), WC_EDM_DB::get_groups() ) ); + } + + /** + * GET /groups/{id} + * + * @param WP_REST_Request $request Request. + * @return WP_REST_Response|WP_Error + */ + public function get_group( $request ) { + $group = WC_EDM_DB::get_group( (int) $request['id'] ); + return $group ? rest_ensure_response( $this->format_group( $group ) ) : $this->not_found(); + } + + /** + * POST /groups + * + * @param WP_REST_Request $request Request. + * @return WP_REST_Response|WP_Error + */ + public function create_group( $request ) { + $name = sanitize_text_field( (string) $request['name'] ); + + if ( '' === $name ) { + return new WP_Error( 'wc_edm_invalid', __( 'A group name is required.', 'wc-email-discount' ), array( 'status' => 400 ) ); + } + + $id = WC_EDM_DB::insert_group( + array( + 'name' => $name, + 'discount_percent' => WC_EDM_Helpers::sanitize_percent( $request['discount_percent'] ?? 0 ), + ) + ); + + if ( ! $id ) { + return new WP_Error( 'wc_edm_db_error', __( 'Could not create the group.', 'wc-email-discount' ), array( 'status' => 500 ) ); + } + + if ( is_array( $request['category_ids'] ) ) { + WC_EDM_DB::set_group_categories( $id, array_map( 'absint', $request['category_ids'] ) ); + } + + return rest_ensure_response( $this->format_group( WC_EDM_DB::get_group( $id ) ) ); + } + + /** + * PUT/PATCH /groups/{id} + * + * @param WP_REST_Request $request Request. + * @return WP_REST_Response|WP_Error + */ + public function update_group( $request ) { + $id = (int) $request['id']; + $group = WC_EDM_DB::get_group( $id ); + + if ( ! $group ) { + return $this->not_found(); + } + + $name = null !== $request['name'] ? sanitize_text_field( (string) $request['name'] ) : $group->name; + $percent = null !== $request['discount_percent'] ? WC_EDM_Helpers::sanitize_percent( $request['discount_percent'] ) : (float) $group->discount_percent; + + if ( '' === $name ) { + return new WP_Error( 'wc_edm_invalid', __( 'A group name is required.', 'wc-email-discount' ), array( 'status' => 400 ) ); + } + + WC_EDM_DB::update_group( + $id, + array( + 'name' => $name, + 'discount_percent' => $percent, + ) + ); + + if ( is_array( $request['category_ids'] ) ) { + WC_EDM_DB::set_group_categories( $id, array_map( 'absint', $request['category_ids'] ) ); + } + + return rest_ensure_response( $this->format_group( WC_EDM_DB::get_group( $id ) ) ); + } + + /** + * DELETE /groups/{id} + * + * @param WP_REST_Request $request Request. + * @return WP_REST_Response|WP_Error + */ + public function delete_group( $request ) { + $id = (int) $request['id']; + + if ( ! WC_EDM_DB::get_group( $id ) ) { + return $this->not_found(); + } + + WC_EDM_DB::delete_group( $id ); + + return rest_ensure_response( array( 'deleted' => true, 'id' => $id ) ); + } + + /* + |-------------------------------------------------------------------------- + | Email assignments + |-------------------------------------------------------------------------- + */ + + /** + * GET /emails + * + * @return WP_REST_Response + */ + public function get_emails() { + return rest_ensure_response( array_map( array( $this, 'format_email' ), WC_EDM_DB::get_emails() ) ); + } + + /** + * GET /emails/{id} + * + * @param WP_REST_Request $request Request. + * @return WP_REST_Response|WP_Error + */ + public function get_email( $request ) { + $assignment = WC_EDM_DB::get_email( (int) $request['id'] ); + return $assignment ? rest_ensure_response( $this->format_email( $assignment ) ) : $this->not_found(); + } + + /** + * Validate and normalize email assignment input. + * + * @param WP_REST_Request $request Request. + * @param object|null $existing Existing row when updating. + * @return array|WP_Error + */ + private function prepare_email_data( $request, $existing = null ) { + $pattern = null !== $request['email_pattern'] + ? strtolower( sanitize_text_field( (string) $request['email_pattern'] ) ) + : ( $existing ? $existing->email_pattern : '' ); + + $type = null !== $request['assignment_type'] + ? sanitize_key( (string) $request['assignment_type'] ) + : ( $existing ? $existing->assignment_type : 'group' ); + + if ( '' === $pattern ) { + return new WP_Error( 'wc_edm_invalid', __( 'An email pattern is required.', 'wc-email-discount' ), array( 'status' => 400 ) ); + } + + if ( ! in_array( $type, array( 'group', 'coupon', 'custom' ), true ) ) { + return new WP_Error( 'wc_edm_invalid', __( 'assignment_type must be one of: group, coupon, custom.', 'wc-email-discount' ), array( 'status' => 400 ) ); + } + + $data = array( + 'email_pattern' => $pattern, + 'assignment_type' => $type, + 'group_id' => null, + 'coupon_code' => null, + 'custom_discount' => null, + 'is_active' => null !== $request['is_active'] + ? ( rest_sanitize_boolean( $request['is_active'] ) ? 1 : 0 ) + : ( $existing ? (int) $existing->is_active : 1 ), + 'free_shipping' => null !== $request['free_shipping'] + ? ( rest_sanitize_boolean( $request['free_shipping'] ) ? 1 : 0 ) + : ( $existing ? (int) ! empty( $existing->free_shipping ) : 0 ), + ); + + switch ( $type ) { + case 'group': + $group_id = null !== $request['group_id'] + ? absint( $request['group_id'] ) + : ( $existing && $existing->group_id ? (int) $existing->group_id : 0 ); + + if ( ! $group_id || ! WC_EDM_DB::get_group( $group_id ) ) { + return new WP_Error( 'wc_edm_invalid', __( 'A valid group_id is required for group assignments.', 'wc-email-discount' ), array( 'status' => 400 ) ); + } + + $data['group_id'] = $group_id; + break; + + case 'coupon': + $coupon = null !== $request['coupon_code'] + ? wc_format_coupon_code( (string) $request['coupon_code'] ) + : ( $existing ? (string) $existing->coupon_code : '' ); + + if ( '' === $coupon ) { + return new WP_Error( 'wc_edm_invalid', __( 'A coupon_code is required for coupon assignments.', 'wc-email-discount' ), array( 'status' => 400 ) ); + } + + $data['coupon_code'] = $coupon; + break; + + case 'custom': + $data['custom_discount'] = null !== $request['custom_discount'] + ? WC_EDM_Helpers::sanitize_percent( $request['custom_discount'] ) + : ( $existing && null !== $existing->custom_discount ? (float) $existing->custom_discount : 0 ); + break; + } + + return $data; + } + + /** + * POST /emails + * + * @param WP_REST_Request $request Request. + * @return WP_REST_Response|WP_Error + */ + public function create_email( $request ) { + $data = $this->prepare_email_data( $request ); + + if ( is_wp_error( $data ) ) { + return $data; + } + + $id = WC_EDM_DB::insert_email( $data ); + + if ( ! $id ) { + return new WP_Error( 'wc_edm_db_error', __( 'Could not create the assignment.', 'wc-email-discount' ), array( 'status' => 500 ) ); + } + + return rest_ensure_response( $this->format_email( WC_EDM_DB::get_email( $id ) ) ); + } + + /** + * PUT/PATCH /emails/{id} + * + * @param WP_REST_Request $request Request. + * @return WP_REST_Response|WP_Error + */ + public function update_email( $request ) { + $id = (int) $request['id']; + $existing = WC_EDM_DB::get_email( $id ); + + if ( ! $existing ) { + return $this->not_found(); + } + + $data = $this->prepare_email_data( $request, $existing ); + + if ( is_wp_error( $data ) ) { + return $data; + } + + WC_EDM_DB::update_email( $id, $data ); + + return rest_ensure_response( $this->format_email( WC_EDM_DB::get_email( $id ) ) ); + } + + /** + * DELETE /emails/{id} + * + * @param WP_REST_Request $request Request. + * @return WP_REST_Response|WP_Error + */ + public function delete_email( $request ) { + $id = (int) $request['id']; + + if ( ! WC_EDM_DB::get_email( $id ) ) { + return $this->not_found(); + } + + WC_EDM_DB::delete_email( $id ); + + return rest_ensure_response( array( 'deleted' => true, 'id' => $id ) ); + } + + /* + |-------------------------------------------------------------------------- + | Shipping + |-------------------------------------------------------------------------- + */ + + /** + * GET /shipping-methods — all shipping method instances across zones. + * + * @return WP_REST_Response + */ + public function get_shipping_methods() { + return rest_ensure_response( array_values( WC_EDM_Helpers::get_all_shipping_methods() ) ); + } + + /** + * GET /groups/{id}/shipping-rules + * + * @param WP_REST_Request $request Request. + * @return WP_REST_Response|WP_Error + */ + public function get_shipping_rules( $request ) { + $group_id = (int) $request['id']; + + if ( ! WC_EDM_DB::get_group( $group_id ) ) { + return $this->not_found(); + } + + $rules = array(); + + foreach ( WC_EDM_DB::get_shipping_rules( $group_id ) as $rule ) { + $rules[] = array( + 'shipping_method_id' => $rule->shipping_method_id, + 'action' => $rule->action, + 'sort_order' => (int) $rule->sort_order, + ); + } + + return rest_ensure_response( $rules ); + } + + /** + * PUT /groups/{id}/shipping-rules — replace all rules for the group. + * + * Body: { "rules": [ { "shipping_method_id": "flat_rate:3", "action": "show" }, ... ] } + * Sort order follows array order. An empty rules array clears all rules. + * + * @param WP_REST_Request $request Request. + * @return WP_REST_Response|WP_Error + */ + public function update_shipping_rules( $request ) { + $group_id = (int) $request['id']; + + if ( ! WC_EDM_DB::get_group( $group_id ) ) { + return $this->not_found(); + } + + $raw = $request['rules']; + + if ( ! is_array( $raw ) ) { + return new WP_Error( 'wc_edm_invalid', __( 'A rules array is required.', 'wc-email-discount' ), array( 'status' => 400 ) ); + } + + $rules = array(); + + foreach ( array_values( $raw ) as $index => $rule ) { + $method_id = isset( $rule['shipping_method_id'] ) ? sanitize_text_field( (string) $rule['shipping_method_id'] ) : ''; + $action = isset( $rule['action'] ) ? sanitize_key( (string) $rule['action'] ) : ''; + + if ( '' === $method_id || ! in_array( $action, array( 'show', 'hide' ), true ) ) { + return new WP_Error( + 'wc_edm_invalid', + __( 'Each rule needs a shipping_method_id and an action of "show" or "hide".', 'wc-email-discount' ), + array( 'status' => 400 ) + ); + } + + $rules[] = array( + 'shipping_method_id' => $method_id, + 'action' => $action, + 'sort_order' => $index, + ); + } + + WC_EDM_DB::replace_shipping_rules( $group_id, $rules ); + + return $this->get_shipping_rules( $request ); + } + + /* + |-------------------------------------------------------------------------- + | Audit log + |-------------------------------------------------------------------------- + */ + + /** + * GET /audit-log — recent REST activity, newest first. + * + * @param WP_REST_Request $request Request. + * @return WP_REST_Response + */ + public function get_audit_log( $request ) { + $limit = min( 200, max( 1, (int) $request['limit'] ) ); + $offset = max( 0, (int) $request['offset'] ); + $outcome = $request['outcome'] ? sanitize_key( (string) $request['outcome'] ) : ''; + + $entries = array(); + + foreach ( WC_EDM_DB::get_audit_entries( $limit, $offset, $outcome ) as $entry ) { + $entries[] = array( + 'id' => (int) $entry->id, + 'created_at' => $entry->created_at, + 'user_id' => (int) $entry->user_id, + 'user_login' => $entry->user_login, + 'ip' => $entry->ip, + 'method' => $entry->method, + 'route' => $entry->route, + 'status' => (int) $entry->status, + 'outcome' => $entry->outcome, + 'details' => $entry->details ? json_decode( $entry->details, true ) : null, + ); + } + + $response = rest_ensure_response( $entries ); + $response->header( 'X-WP-Total', (string) WC_EDM_DB::count_audit_entries( $outcome ) ); + + return $response; + } + + /* + |-------------------------------------------------------------------------- + | Match test + |-------------------------------------------------------------------------- + */ + + /** + * GET /match?email=... — test which assignment an email would get. + * + * @param WP_REST_Request $request Request. + * @return WP_REST_Response + */ + public function match_email( $request ) { + $email = sanitize_email( (string) $request['email'] ); + $match = $email ? WC_EDM_Helpers::find_email_match( $email ) : null; + + return rest_ensure_response( + array( + 'email' => $email, + 'matched' => (bool) $match, + 'match' => $match ? $this->format_email( $match ) : null, + ) + ); + } +} diff --git a/woocommerce-email-discount-manager.php b/woocommerce-email-discount-manager.php new file mode 100644 index 0000000..3e73d10 --- /dev/null +++ b/woocommerce-email-discount-manager.php @@ -0,0 +1,263 @@ +includes(); + + if ( ! empty( $this->missing_files ) ) { + add_action( 'admin_notices', array( $this, 'incomplete_install_notice' ) ); + } + + // Everything else builds on these two, so without them the plugin + // stays dormant and shows the notice instead of erroring out. + if ( ! class_exists( 'WC_EDM_DB' ) || ! class_exists( 'WC_EDM_Helpers' ) ) { + return; + } + + // Upgrade the DB schema after plugin updates that skip re-activation. + add_action( 'init', array( 'WC_EDM_DB', 'maybe_upgrade' ), 5 ); + + if ( is_admin() && class_exists( 'WC_EDM_Admin' ) ) { + $this->admin = new WC_EDM_Admin(); + } + + if ( class_exists( 'WC_EDM_Frontend' ) ) { + $this->frontend = new WC_EDM_Frontend(); + } + + if ( class_exists( 'WC_EDM_Audit_Log' ) ) { + $this->audit_log = new WC_EDM_Audit_Log(); + } + + if ( class_exists( 'WC_EDM_REST_API' ) ) { + $this->rest_api = new WC_EDM_REST_API(); + } + } + + /** + * Warn that the plugin folder is incomplete. + */ + public function incomplete_install_notice() { + if ( ! current_user_can( 'activate_plugins' ) ) { + return; + } + + echo '

    '; + echo esc_html__( 'WooCommerce Email Discount Manager could not load all of its files, so some features are unavailable. If you just updated the plugin, reload this page — the upload may still have been in progress. If the warning persists, upload the plugin again.', 'wc-email-discount' ); + echo '

    '; + echo implode( ', ', array_map( 'esc_html', $this->missing_files ) ); // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped + echo '

    '; + } + + /** + * Load class files. + */ + private function includes() { + $files = array( + 'includes/class-db.php', + 'includes/class-helpers.php', + 'includes/class-frontend.php', + 'includes/class-audit-log.php', + 'includes/class-rate-limiter.php', + 'includes/class-rest-api.php', + ); + + if ( is_admin() ) { + $files = array_merge( + $files, + array( + 'includes/class-admin.php', + 'includes/class-admin-groups.php', + 'includes/class-admin-emails.php', + 'includes/class-admin-shipping.php', + 'includes/class-admin-audit.php', + ) + ); + } + + foreach ( $files as $file ) { + $path = WC_EDM_PLUGIN_DIR . $file; + + // A missing file is recorded rather than fatal, so a half-copied + // upgrade cannot take down wp-admin. + if ( is_readable( $path ) ) { + require_once $path; + } else { + $this->missing_files[] = $file; + } + } + } + + /** + * Declare compatibility with WooCommerce HPOS. + */ + public function declare_wc_compatibility() { + if ( class_exists( '\Automattic\WooCommerce\Utilities\FeaturesUtil' ) ) { + \Automattic\WooCommerce\Utilities\FeaturesUtil::declare_compatibility( 'custom_order_tables', WC_EDM_PLUGIN_FILE, true ); + } + } + + /** + * Admin notice shown when WooCommerce is not active. + */ + public function woocommerce_missing_notice() { + echo '

    '; + esc_html_e( 'WooCommerce Email Discount Manager requires WooCommerce to be installed and active.', 'wc-email-discount' ); + echo '

    '; + } + + /** + * Plugin activation: verify WooCommerce and create database tables. + */ + public static function activate() { + $active_plugins = apply_filters( 'active_plugins', get_option( 'active_plugins', array() ) ); + + if ( ! class_exists( 'WooCommerce' ) && ! in_array( 'woocommerce/woocommerce.php', $active_plugins, true ) ) { + deactivate_plugins( plugin_basename( WC_EDM_PLUGIN_FILE ) ); + wp_die( + esc_html__( 'WooCommerce Email Discount Manager requires WooCommerce to be installed and active. Please activate WooCommerce first.', 'wc-email-discount' ), + esc_html__( 'Plugin activation error', 'wc-email-discount' ), + array( 'back_link' => true ) + ); + } + + require_once WC_EDM_PLUGIN_DIR . 'includes/class-db.php'; + WC_EDM_DB::create_tables(); + } + + /** + * Plugin deactivation: clear scheduled jobs. Data is left untouched. + */ + public static function deactivate() { + require_once WC_EDM_PLUGIN_DIR . 'includes/class-audit-log.php'; + WC_EDM_Audit_Log::unschedule_prune(); + } +} + +register_activation_hook( __FILE__, array( 'WC_Email_Discount_Manager', 'activate' ) ); +register_deactivation_hook( __FILE__, array( 'WC_Email_Discount_Manager', 'deactivate' ) ); + +/** + * Access the main plugin instance. + * + * @return WC_Email_Discount_Manager + */ +function wc_edm() { + return WC_Email_Discount_Manager::instance(); +} + +wc_edm();