WP Manifestindependent plugin directory
manifest / ecommerce / woo-cart-rescue

WooCommerce Cart Rescue

WooCommerce abandoned-cart recovery with consent-gated capture, HMAC-signed restore links, race-safe email sequences, and revenue attribution.

by thealirazadev · github.com/thealirazadev/woo-cart-rescue · website

0stars
0forks

Install

No release zip yet. The repository archive installs, but the folder name will carry the branch suffix and updates will not flow:

wp plugin install https://github.com/thealirazadev/woo-cart-rescue/archive/refs/heads/main.zip

A WooCommerce plugin that recovers abandoned carts. It tracks carts for logged-in customers and for guests who give explicit consent at checkout, detects abandonment after a configurable idle window, sends a scheduled recovery email sequence with signed restore links that rebuild the cart, can attach an optional single-use recovery coupon to any step, and reports recovered orders and revenue in the WooCommerce admin. Consent, data retention, and token security are first-class requirements, not afterthoughts.

Stack: WordPress plugin in PHP 8.1+, WooCommerce hooks and email classes, Action Scheduler for all timing, custom database tables with versioned migrations, vanilla JS for the checkout capture script, PHPUnit and PHPCS (WordPress Coding Standards) for quality gates.

Status: v1.1.0 implemented (phases 1-6 complete). Phase 6 adds optional auto-generated recovery coupons.

Screenshots

Recovery report showing stat cards for carts abandoned, emails sent with a per-step breakdown, recovered orders, recovered revenue, and recovery rate over a date range

Settings screen with the general capture options and a configured three-step recovery email sequence, each step showing its enable toggle and delay

Install

  1. Copy this folder to wp-content/plugins/woo-cart-rescue on a site with WooCommerce active.
  2. Run composer install for the development tooling (not required at runtime).
  3. Activate the plugin. Activation is blocked with an admin notice if WooCommerce is not active.
  4. Configure it under WooCommerce > Cart Rescue, and edit the per-step email subject and heading under WooCommerce > Settings > Emails.

Run

Local development uses @wordpress/env (Docker):

npx wp-env start          # local WordPress + WooCommerce

Set the idle window and step delays to 1-2 minutes and run due actions from Tools > Scheduled Actions to exercise the timing flows quickly. Outbound mail in the dev site goes to a local mail catcher (for example Mailpit).

Test

composer install         # dev tooling (exact-pinned, lockfile committed)
composer run lint        # PHPCS against phpcs.xml.dist (WordPress Coding Standards)
composer run test        # PHPUnit

The test suite runs in two modes. With WP_TESTS_DIR pointing at a WordPress core test library the full integration tests run against real WordPress, WooCommerce, and Action Scheduler; CI runs them on every push, and docs/testing.md has the exact provisioning commands (wp-env works too but is not required). Without it, a unit subset (token signing, settings sanitization, merge-tag rendering) runs with lightweight stubs and the integration test files no-op so the command still passes.

Design decisions

The trade-offs below are the load-bearing ones. Each records the alternative that was rejected and why, so the reasoning survives past the original author.

Consent-gated guest capture

A guest cart is only stored after the shopper enters an email and ticks an unchecked-by-default consent box at checkout; the browser posts both to an AJAX endpoint that re-validates the nonce, the email, and consent === "1" server-side before writing anything. Logged-in customers are captured on cart activity under the existing account relationship. Rejected: silently capturing any email typed into the checkout field. That would store personal data without a lawful basis and is exactly the pattern this plugin exists to avoid.

No open or click tracking pixels

Recovery emails carry a signed restore link and an unsubscribe link and nothing else - no tracking pixel, no click-through redirector. The report surfaces outcomes that matter (carts abandoned, emails sent per step, recovered orders and revenue, recovery rate) and states plainly that opens are not tracked. Rejected: an open/click pixel. It leaks the shopper's activity to the store for a metric that does not change what the sequence does, and it undercuts the plugin's privacy posture.

Custom tables over postmeta

Cart records live in four $wpdb-prefixed tables (wcr_carts, wcr_sends, wcr_events, wcr_optouts) created through dbDelta with a versioned migration runner. Carts are not posts, guests have no user row, the abandonment sweep needs an indexed (status, last_activity_at) scan, duplicate-send protection needs a unique (cart_id, step) key, and the report needs aggregate SUM/COUNT. Postmeta offers none of that and would force fake posts and unindexed LIKE queries. Trade-off accepted: we own schema migrations, versioned through the wcr_db_version option and an idempotent, resumable upgrade routine.

Hashed-email opt-out table

An unsubscribe records only sha256(lowercased, trimmed email) in wcr_optouts - never a plaintext address. The sweep, the send-time recheck, and guest capture all consult it, so a suppression outlives the cart it came from: retention purges delete the cart row and anonymization nulls its email, but the opt-out hash remains and keeps future carts for that address suppressed. Rejected: storing the opt-out as a flag on the cart row. It would vanish the moment the cart was purged or anonymized, silently resurrecting a suppressed address.

Action Scheduler for all timing

The abandonment sweep, every send, the step chaining, and the daily retention cleanup all run as Action Scheduler actions in a dedicated group (Action Scheduler ships bundled with WooCommerce, so this adds no dependency). It survives missed cron ticks, supports group-wide unscheduling, and has an admin UI for inspection. Rejected: raw WP-Cron, which silently drops overdue events on low-traffic sites - fatal for a timing-sensitive email sequence. The send row's status, not the scheduled action, is always authoritative and is reconciled at run time.

Restore click resets the cart to active

Clicking a restore link rebuilds the session cart, marks the token used, and returns the cart row to active with a fresh last_activity_at. A restored cart is a live shopping session again: if the shopper completes checkout the order hook attributes and closes it; if they wander off, the sweep re-abandons it after the idle window and the sequence resumes at the first unsent step rather than repeating one already delivered. Rejected: leaving the cart abandoned after a restore, which would let the in-flight sequence keep firing against someone actively shopping.

Token security and race safety

Restore/unsubscribe tokens are {send_id}.{expires}.{nonce}.{sig} where sig is an HMAC-SHA256 over the first three parts under a per-site 32-byte secret. Only sha256(token) is stored on the send row, so a database leak exposes no usable link; verification is timing-safe (hash_equals), enforces expiry, and the single-use claim is an atomic UPDATE ... WHERE token_used_at IS NULL. The secret is dedicated rather than a wp_salt() derivative so unrelated salt rotation never invalidates every outstanding link. Sends are race-safe by construction: the worker claims a row with UPDATE ... WHERE status = 'scheduled', then rechecks cart state as late as possible before dispatch, and both the order-placed and unsubscribe paths flip the cart status before cancelling sends so an in-flight worker stops itself. Rejected: distributed locks or a queue broker - the conditional-update pattern is sufficient and adds no infrastructure.

Auto-generated recovery coupons (opt-in, per step, single-use)

Coupons are off by default. When the feature is enabled and a step's coupon toggle is on, the race-safe send path mints a fresh WC_Coupon for that one send: a unique code, usage_limit = 1, and an expiry set from the configured validity window. The code is stored on the send row, exposed to the email subject, heading, and body through a {coupon_code} merge tag, and applied automatically when the restore link is followed (an expired or missing coupon is skipped silently, never surfacing a discount error). Expired auto-generated coupons are deleted by the daily retention job. Rejected: a single shared static coupon linked in every email. It cannot be single-use, cannot be tied to one shopper, leaks across the web the moment one recipient posts it, and cannot expire per send. The per-send generated coupon instead gives each reminder its own disposable, expiring incentive. Rejected too: discounting every step, which trains shoppers to abandon on purpose; the default attaches a coupon only to the final reminder.