Files
nolimit-discounts/docs/ARCHITECTURE.md
kristianwindandClaude Opus 5 dccb87d15e Document the plugin, and check that the documentation still describes it
Five files under docs/, the names the family agreed: README, SETUP,
ARCHITECTURE, CONTRACT, RELEASE. Plus ci/check-docs.php, ported from
woo-security-manager 52dd99d, wired into the static job.

CONTRACT.md is generated from the source rather than written from memory, and
that was the right instruction: my first sweep read only single-quoted hook
names and missed two rows, one of them update_plugins_{$hostname} — the hook
behind the live-shop collision, written with interpolation and therefore
invisible to a pattern built from the concept of the line. Nine hooks
documented: six wc_edm_* filters this plugin offers, three wpmgr_* it both
offers and answers through its copy of the frozen shared-menu.php.

Two hooks the checker structurally cannot see are listed by hand with a note
saying so: wc_edm_prune_audit_log (add_action on a class constant) and
update_plugins_{$hostname} (interpolated). A generated list beats a remembered
one and is still a list of what the generator can see.

The checker needed two fixes before it was safe to run here.

🔴 The published version reads HOOK_PREFIX from ci/plugin.conf for the
documented side, and refuses without it — then tests the source side against a
hardcoded 'nlsec_'. Ported verbatim that is not a green over nothing, which
would be easy to spot. Measured in this tree with CONTRACT.md documenting no
hook at all:

  3 hook(s) in the shipped source and not in docs/CONTRACT.md:
  wpmgr_dashboard_cards, wpmgr_menu_glyph, wpmgr_menu_title

Red, specific, and blind to all six wc_edm_* hooks — because 'wpmgr_' is the
same string in every tree and 'nlsec_' is the one that is not. Fix those three
and it goes green over six undocumented contract hooks, with a red run behind
you as evidence that it works. A partially blind check that fails is more
convincing than one that passes.

Note which step disarmed the guard. The floor at 20 shipped PHP files refused
to run in this 14-file tree, and that refusal was the only thing standing in
the way; the porting instructions were to add HOOK_PREFIX and adjust the floor.
Doing as told is what removed the protection — two wrongs cancelling into a
pass, in the order CLAUDE.md predicts, because the floor is the error you can
see and the prefix is not.

Both numbers now come from the conf (HOOK_PREFIX, MIN_SHIPPED_PHP_FILES) and
both refuse rather than defaulting. The inherited literal 20 is gone: a second
hardcoded number per plugin is a second thing to re-measure on every port.

Ten selftest assertions, 58 total. Four failure modes, the two refusals, the
counts on the pass, and the row that matters most:

🔴 A hook name in a COMMENT must not read as a hook call. CONTRACT.md documents
a filter by showing the apply_filters() a consumer makes, and the same example
sits in a docblock — so a checker that reads comments reports this plugin
consuming a filter it answers, in the file explaining why it must not. That
assertion is a control asserting a PASS, which is the kind that can quietly
become unable to fail, so it names the specific success line rather than
reading an exit code, and it is proven both ways: with token_get_all's
T_COMMENT branch made inert, exactly one of 58 assertions fails and it is that
one.

docs/TESTING-AND-RELEASES.md is folded into RELEASE.md and deleted rather than
left beside it — two copies of a rule is how the copies start to disagree.
Folding surfaced that it still documented the pre-rename Update URI
(kw/woocommerce-email-discount-manager) and still recommended, as future work,
the split into a releases-only repository that has already happened. Both
corrected in place, with the correction dated.

docs/ is not in ci/dist-files.sh — measured, 0 docs files in the zip.

static=0 selftest=0 site=0; site still 69 assertions on WordPress 7.1.2 with
WooCommerce 11.1.2. ci/ and docs/ ship nothing, so no version bump.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-28 21:45:29 +02:00

139 lines
5.9 KiB
Markdown

# 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.