# Architecture ## The files that ship 18 files. `ci/`, `tests/` and `docs/` are not among them — `ci/dist-files.sh` decides, and a static check asserts that every PHP file the code loads is in that list. | file | what it is | | --- | --- | | `woocommerce-email-discount-manager.php` | header, constants, boot order, activation and deactivation | | `includes/class-db.php` | the five tables, `dbDelta()` schema, and every query | | `includes/class-helpers.php` | matching an email to an assignment | | `includes/class-frontend.php` | cart totals and shipping filtering | | `includes/class-admin.php` | the menu, the three tabs, the dashboard card | | `includes/class-admin-groups.php` | the Groups tab | | `includes/class-admin-emails.php` | the Emails tab | | `includes/class-admin-shipping.php` | the Shipping tab | | `includes/class-admin-audit.php` | the audit log viewer | | `includes/class-audit-log.php` | writing and pruning the audit log | | `includes/class-rate-limiter.php` | per-method REST rate limiting | | `includes/class-rest-api.php` | the `wc-edm/v1` routes | | `includes/class-updater.php` | the release-feed update check | | `includes/shared-menu.php` | the family's shared parent menu — byte-identical in all eight plugins | | `assets/css/admin.css`, `assets/js/admin.js`, `assets/icon.svg` | admin UI and the icon WordPress draws on the Updates screen | | `README.txt` | the readme WordPress parses | ## Boot order The order matters and two of the steps are deliberately before the WooCommerce check. 1. **File load.** Constants are defined: `WC_EDM_VERSION`, `WC_EDM_PLUGIN_FILE`, `WC_EDM_PLUGIN_DIR`, `WC_EDM_PLUGIN_URL`. 2. **`plugins_loaded`** → `init()`. 3. **The updater loads first**, before the WooCommerce check. A shop with WooCommerce switched off for an afternoon must not quietly stop receiving fixes for this plugin. 4. **The shared menu title filter is added**, also before that check, and for a related reason: the shared parent menu is registered by whichever family plugin boots first, and its label must not depend on whether *this* plugin got past a gate. 5. **WooCommerce check.** Missing → an admin notice, and `init()` returns. The plugin is dormant, not broken. 6. Text domain, then `includes()`. A file that failed to load is collected and reported as a notice rather than fataling. 7. **`WC_EDM_DB` and `WC_EDM_Helpers` are required to continue.** Without either, `init()` returns and the notice stands — everything else builds on them. 8. `init` priority 5 → `WC_EDM_DB::maybe_upgrade()`, so a schema change lands after an update that skipped re-activation. 9. Admin, frontend, audit log and REST objects are constructed, each behind a `class_exists()` check. The shared menu is registered under `WPMGR_MENU_SLUG` with a `function_exists( 'wpmgr_ensure_menu' )` fallback, so a site where an older sibling's copy of `shared-menu.php` loads first still gets a menu. ## Where data lives ### Tables Five, all prefixed `wp_wc_edm_` (whatever the site's `$wpdb->prefix` is), created by `dbDelta()` with foreign keys added afterwards: - `wp_wc_edm_groups` — discount groups - `wp_wc_edm_group_categories` — group → product category - `wp_wc_edm_emails` — email patterns and their assignment - `wp_wc_edm_shipping_rules` — group → shipping method, show or hide - `wp_wc_edm_audit_log` — admin actions ### Options - `wc_edm_db_version` — schema version, the only thing `maybe_upgrade()` reads That is the complete list. This plugin stores no settings in options; the three tabs write to the tables. ### Transients - `wc_edm_release_feed` — the update feed response - `wc_edm_card_counts` — the two numbers on the dashboard card, 5 minutes ### Scheduled events One: `wc_edm_prune_audit_log`, daily, scheduled on `init` if not already present and unscheduled on deactivation. It deletes audit rows older than the retention window. ## Lifecycle **Activate** (`register_activation_hook`) — refuses with an error if WooCommerce is not active, then creates the tables. **Deactivate** (`register_deactivation_hook`) — unschedules the prune event. Tables, options and transients stay. **Uninstall** — 🔴 **nothing happens.** There is no `uninstall.php` and no `register_uninstall_hook`, so deleting the plugin in wp-admin removes the files and leaves all five tables, both options and the transients in the database. Deliberate: deleting a plugin by accident should not destroy a shop's discount configuration. The cost is that a genuine clean removal is manual — drop the five `wp_wc_edm_*` tables and delete `wc_edm_db_version` by hand. ## The customer-facing path `woocommerce_cart_calculate_fees` (priority 20) adds the discount as a negative fee. `woocommerce_before_calculate_totals` (priority 20) applies a coupon for the Coupon assignment type. `woocommerce_package_rates` (priority 20, 2 args) filters shipping. The shipping filter's shape is the part worth reading in full, because a subtle version of it shipped free delivery to strangers for several releases: ```php $match = $this->get_current_match(); if ( $match && 'group' === $match->assignment_type && $match->group_id ) { $rates = $this->apply_group_shipping_rules( $rates, (int) $match->group_id ); } else { $rates = $this->remove_restricted_rates( $rates ); } ``` The `else` is the fix. A customer who matches nothing must still have the reserved methods taken away; before 1.6.0 that branch did not exist, so the restriction only applied to people who matched a group — i.e. to nobody who mattered. `apply_group_shipping_rules()` also falls back to `remove_restricted_rates()` when a group has no rules at all, for the same reason. ## Not present - No drop-in, no mu-plugin, nothing that runs before WordPress boots. - Nothing hooks `the_content` or buffers output. - No cron beyond the one prune event. - No custom post types, no taxonomies, no user meta. - No `WP_CLI` commands.