From e4a40e53a0e26fba9fc42e699e508173793e6b89 Mon Sep 17 00:00:00 2001
From: kristianwind
Date: Sun, 23 Aug 2026 15:38:14 +0200
Subject: [PATCH] Initial commit: WooCommerce Email Discount Manager plugin
---
.gitignore | 5 +
README.txt | 115 ++++
assets/css/admin.css | 236 ++++++++
assets/js/admin.js | 87 +++
includes/class-admin-audit.php | 182 ++++++
includes/class-admin-emails.php | 345 +++++++++++
includes/class-admin-groups.php | 240 ++++++++
includes/class-admin-shipping.php | 195 ++++++
includes/class-admin.php | 328 ++++++++++
includes/class-audit-log.php | 153 +++++
includes/class-db.php | 647 ++++++++++++++++++++
includes/class-frontend.php | 413 +++++++++++++
includes/class-helpers.php | 224 +++++++
includes/class-rate-limiter.php | 162 +++++
includes/class-rest-api.php | 796 +++++++++++++++++++++++++
woocommerce-email-discount-manager.php | 263 ++++++++
16 files changed, 4391 insertions(+)
create mode 100644 .gitignore
create mode 100644 README.txt
create mode 100644 assets/css/admin.css
create mode 100644 assets/js/admin.js
create mode 100644 includes/class-admin-audit.php
create mode 100644 includes/class-admin-emails.php
create mode 100644 includes/class-admin-groups.php
create mode 100644 includes/class-admin-shipping.php
create mode 100644 includes/class-admin.php
create mode 100644 includes/class-audit-log.php
create mode 100644 includes/class-db.php
create mode 100644 includes/class-frontend.php
create mode 100644 includes/class-helpers.php
create mode 100644 includes/class-rate-limiter.php
create mode 100644 includes/class-rest-api.php
create mode 100644 woocommerce-email-discount-manager.php
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( $( '' ).attr( 'type', 'button' ).addClass( 'wc-edm-remove-category' ).html( '×' ) )
+ .append( $( ' ' ).attr( { type: 'hidden', name: 'category_ids[]' } ).val( id ) );
+
+ $categoryList.append( $item );
+ $option.prop( 'disabled', true );
+ $categorySelect.val( '' );
+ } );
+
+ $categoryList.on( 'click', '.wc-edm-remove-category', function() {
+ var $item = $( this ).closest( 'li' );
+ var id = String( $item.data( 'id' ) );
+
+ $categorySelect.find( 'option[value="' + id + '"]' ).prop( 'disabled', false );
+ $item.remove();
+ } );
+
+ // ---------------------------------------------------------------
+ // Email edit: toggle fields for the selected assignment type.
+ // ---------------------------------------------------------------
+ function toggleAssignmentFields() {
+ var type = $( '#wc-edm-assignment-type input[name="assignment_type"]:checked' ).val();
+
+ $( '.wc-edm-type-fields' ).each( function() {
+ $( this ).toggle( $( this ).data( 'type' ) === type );
+ } );
+ }
+
+ if ( $( '#wc-edm-assignment-type' ).length ) {
+ toggleAssignmentFields();
+ $( '#wc-edm-assignment-type' ).on( 'change', 'input[name="assignment_type"]', toggleAssignmentFields );
+ }
+
+ // ---------------------------------------------------------------
+ // Confirm before deleting.
+ // ---------------------------------------------------------------
+ $( document ).on( 'click', '.wc-edm-delete', function( e ) {
+ if ( ! window.confirm( wcEdmAdmin.confirmDelete ) ) {
+ e.preventDefault();
+ }
+ } );
+
+ // ---------------------------------------------------------------
+ // Shipping tab: auto-submit the group selector on change.
+ // ---------------------------------------------------------------
+ $( '#wc-edm-shipping-group' ).on( 'change', function() {
+ $( this ).closest( 'form' ).trigger( 'submit' );
+ } );
+ } );
+
+}( jQuery ) );
diff --git a/includes/class-admin-audit.php b/includes/class-admin-audit.php
new file mode 100644
index 0000000..816856c
--- /dev/null
+++ b/includes/class-admin-audit.php
@@ -0,0 +1,182 @@
+ 'audit', 'message' => 'deleted' ) ) );
+ exit;
+ }
+
+ /**
+ * Render the audit log tab.
+ */
+ public function render() {
+ $outcome = isset( $_GET['outcome'] ) ? sanitize_key( wp_unslash( $_GET['outcome'] ) ) : ''; // phpcs:ignore WordPress.Security.NonceVerification.Recommended
+ $outcome = in_array( $outcome, array( 'allowed', 'denied', 'rate_limited' ), true ) ? $outcome : '';
+
+ $paged = isset( $_GET['paged'] ) ? max( 1, absint( $_GET['paged'] ) ) : 1; // phpcs:ignore WordPress.Security.NonceVerification.Recommended
+ $offset = ( $paged - 1 ) * self::PER_PAGE;
+
+ $entries = WC_EDM_DB::get_audit_entries( self::PER_PAGE, $offset, $outcome );
+ $total = WC_EDM_DB::count_audit_entries( $outcome );
+
+ $counts = array(
+ '' => WC_EDM_DB::count_audit_entries(),
+ 'allowed' => WC_EDM_DB::count_audit_entries( 'allowed' ),
+ 'denied' => WC_EDM_DB::count_audit_entries( 'denied' ),
+ 'rate_limited' => WC_EDM_DB::count_audit_entries( 'rate_limited' ),
+ );
+
+ $labels = array(
+ '' => __( 'All', 'wc-email-discount' ),
+ 'allowed' => __( 'Changes', 'wc-email-discount' ),
+ 'denied' => __( 'Rejected', 'wc-email-discount' ),
+ 'rate_limited' => __( 'Rate limited', 'wc-email-discount' ),
+ );
+
+ $clear_url = wp_nonce_url(
+ WC_EDM_Helpers::admin_url( array( 'tab' => 'audit', 'action' => 'clear_audit_log' ) ),
+ 'wc_edm_clear_audit_log'
+ );
+ ?>
+
+
+
+
+
+
+
+
+ $label ) : ?>
+
+
+
+
+ ()
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ details ? json_decode( $entry->details, true ) : array();
+ $badge = array(
+ 'allowed' => 'wc-edm-badge-group',
+ 'denied' => 'wc-edm-badge-denied',
+ 'rate_limited' => 'wc-edm-badge-coupon',
+ );
+ ?>
+
+ created_at ) ); ?>
+
+ user_login ) : ?>
+ user_login ); ?>
+
+
+
+
+ ip ? $entry->ip : '—' ); ?>
+
+ method ); ?>
+ route ); ?>
+
+
+
+ outcome ] ?? $entry->outcome ); ?>
+
+
+ status ); ?>
+
+
+
+
+
+
+
+
+ 1 ) :
+ ?>
+
+ esc_url_raw( add_query_arg( 'paged', '%#%' ) ),
+ 'format' => '',
+ 'current' => $paged,
+ 'total' => $pages,
+ 'prev_text' => '«',
+ 'next_text' => '»',
+ )
+ )
+ );
+ ?>
+
+ 'emails', 'message' => 'error' ) ) );
+ exit;
+ }
+
+ $data = array(
+ 'email_pattern' => $pattern,
+ 'assignment_type' => $type,
+ 'group_id' => null,
+ 'coupon_code' => null,
+ 'custom_discount' => null,
+ 'is_active' => isset( $_POST['is_active'] ) ? 1 : 0,
+ 'free_shipping' => isset( $_POST['free_shipping'] ) ? 1 : 0,
+ );
+
+ // Only the fields for the chosen type are stored.
+ switch ( $type ) {
+ case 'group':
+ $data['group_id'] = isset( $_POST['group_id'] ) ? absint( $_POST['group_id'] ) : 0;
+ if ( ! $data['group_id'] ) {
+ $data['group_id'] = null;
+ }
+ break;
+
+ case 'coupon':
+ $data['coupon_code'] = isset( $_POST['coupon_code'] )
+ ? wc_format_coupon_code( wp_unslash( $_POST['coupon_code'] ) )
+ : '';
+ break;
+
+ case 'custom':
+ $data['custom_discount'] = isset( $_POST['custom_discount'] )
+ ? WC_EDM_Helpers::sanitize_percent( $_POST['custom_discount'] )
+ : 0;
+ break;
+ }
+
+ if ( $id ) {
+ WC_EDM_DB::update_email( $id, $data );
+ } else {
+ $id = WC_EDM_DB::insert_email( $data );
+ }
+
+ wp_safe_redirect( WC_EDM_Helpers::admin_url( array( 'tab' => 'emails', '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_email_' . $id );
+
+ WC_EDM_DB::delete_email( $id );
+
+ wp_safe_redirect( WC_EDM_Helpers::admin_url( array( 'tab' => 'emails', 'message' => 'deleted' ) ) );
+ exit;
+ }
+
+ // Toggle active.
+ if ( isset( $_GET['action'], $_GET['id'] ) && 'toggle' === $_GET['action'] ) {
+ $id = absint( $_GET['id'] );
+ check_admin_referer( 'wc_edm_toggle_email_' . $id );
+
+ $assignment = WC_EDM_DB::get_email( $id );
+
+ if ( $assignment ) {
+ WC_EDM_DB::set_email_active( $id, $assignment->is_active ? 0 : 1 );
+ }
+
+ wp_safe_redirect( WC_EDM_Helpers::admin_url( array( 'tab' => 'emails', 'message' => 'saved' ) ) );
+ exit;
+ }
+ }
+
+ /**
+ * Render the emails 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 email assignments.
+ */
+ private function render_list() {
+ $assignments = WC_EDM_DB::get_emails();
+ ?>
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ id;
+
+ $edit_url = WC_EDM_Helpers::admin_url( array( 'tab' => 'emails', 'action' => 'edit', 'id' => $id ) );
+
+ $delete_url = wp_nonce_url(
+ WC_EDM_Helpers::admin_url( array( 'tab' => 'emails', 'action' => 'delete', 'id' => $id ) ),
+ 'wc_edm_delete_email_' . $id
+ );
+
+ $toggle_url = wp_nonce_url(
+ WC_EDM_Helpers::admin_url( array( 'tab' => 'emails', 'action' => 'toggle', 'id' => $id ) ),
+ 'wc_edm_toggle_email_' . $id
+ );
+
+ $type_labels = array(
+ 'group' => __( 'Group', 'wc-email-discount' ),
+ 'coupon' => __( 'Coupon', 'wc-email-discount' ),
+ 'custom' => __( 'Custom', 'wc-email-discount' ),
+ );
+ ?>
+
+ email_pattern ); ?>
+
+
+ assignment_type ] ?? $assignment->assignment_type ); ?>
+
+
+
+
+
+ is_active ? esc_html__( 'Active', 'wc-email-discount' ) : esc_html__( 'Inactive', 'wc-email-discount' ); ?>
+
+
+
+
+
+
+
+
+
+
+ ' . esc_html__( 'Assignment not found.', 'wc-email-discount' ) . '
';
+ return;
+ }
+
+ $type = $assignment ? $assignment->assignment_type : 'group';
+ $is_active = $assignment ? (int) $assignment->is_active : 1;
+ $free_shipping = $assignment ? (int) ! empty( $assignment->free_shipping ) : 0;
+
+ $coupon_codes = WC_EDM_Helpers::get_coupon_codes();
+ $current_coupon = $assignment && $assignment->coupon_code ? $assignment->coupon_code : '';
+
+ // Keep a stored code selectable even if the coupon was since deleted.
+ if ( $current_coupon && ! in_array( $current_coupon, $coupon_codes, true ) ) {
+ array_unshift( $coupon_codes, $current_coupon );
+ }
+ ?>
+
+
+
+ '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 ) ) )
+ );
+ ?>
+
+ 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(); ?>
+
+
+ get_tabs() as $slug => $label ) : ?>
+
+
+
+
+
+
+
+ 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(
+ '',
+ 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();