From 773883a0f3ba4e0e7f36f2e9efd0ffbf2a2f2948 Mon Sep 17 00:00:00 2001 From: Twooey Date: Fri, 28 Aug 2026 12:20:18 -0400 Subject: [PATCH] Evolve the member discount into a sitewide stepped promo MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replaces the flat "$10 off $100+, members only" discount with $5 off per $50 spent, capped at $200 spend ($20 max) — an evolution of the same idea, not an addition to it. Now sitewide by default: the guest tier carries the same step_discount benefit as free_member, so anonymous shoppers get it too, not just members. Toggling it on/off, or switching between sitewide and members-only, needs no code change — an admin unchecks the benefit on one tier's row in the settings screen. Migration (schema v3->v4) overwrites guest/free_member's benefits to the new shape directly rather than merging, per the established pre-launch convention (no real customer/tier data exists to preserve yet). Live-verified beyond the unit suite: the full discount table checked against the real DB for both a guest and a real member, and the sitewide/members-only toggle exercised through a real authenticated settings-screen form POST (not just read back from code). Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01MqhgnCRjjVLFnZ3Jpn93Vg --- .../plugins/bookstore-core/bookstore-core.php | 2 +- .../includes/Admin/TierSettings.php | 18 ++-- .../bookstore-core/includes/Cli/Commands.php | 12 +-- .../includes/Membership/MemberDiscount.php | 45 ++++++---- .../includes/Membership/Tier.php | 3 +- .../bookstore-core/includes/Schema.php | 20 +++++ .../Unit/Membership/MemberDiscountTest.php | 84 +++++++++++-------- 7 files changed, 122 insertions(+), 62 deletions(-) diff --git a/wp-content/plugins/bookstore-core/bookstore-core.php b/wp-content/plugins/bookstore-core/bookstore-core.php index 1286622..e87bcf7 100644 --- a/wp-content/plugins/bookstore-core/bookstore-core.php +++ b/wp-content/plugins/bookstore-core/bookstore-core.php @@ -13,7 +13,7 @@ defined('ABSPATH') || exit; define('BSC_PLUGIN_FILE', __FILE__); define('BSC_PLUGIN_DIR', plugin_dir_path(__FILE__)); define('BSC_VERSION', '0.2.0'); -define('BSC_SCHEMA_VERSION', '3'); +define('BSC_SCHEMA_VERSION', '4'); spl_autoload_register(function (string $class) { $prefix = 'Bookstore\\Core\\'; diff --git a/wp-content/plugins/bookstore-core/includes/Admin/TierSettings.php b/wp-content/plugins/bookstore-core/includes/Admin/TierSettings.php index b4e4b46..191f2db 100644 --- a/wp-content/plugins/bookstore-core/includes/Admin/TierSettings.php +++ b/wp-content/plugins/bookstore-core/includes/Admin/TierSettings.php @@ -58,7 +58,7 @@ class TierSettings extends \WC_Settings_Page - + @@ -72,9 +72,10 @@ class TierSettings extends \WC_Settings_Page $ -
- save $ - on orders $+ +
+ $ + off per $ + spent, capped at $ @@ -125,10 +126,11 @@ class TierSettings extends \WC_Settings_Page 'threshold' => (float) ($fields['free_shipping_threshold']['threshold'] ?? 0), ]; } - if (!empty($fields['spend_discount']['enabled'])) { - $benefits['spend_discount'] = [ - 'min_spend' => (float) ($fields['spend_discount']['min_spend'] ?? 0), - 'discount_amount' => (float) ($fields['spend_discount']['discount_amount'] ?? 0), + if (!empty($fields['step_discount']['enabled'])) { + $benefits['step_discount'] = [ + 'step_amount' => (float) ($fields['step_discount']['step_amount'] ?? 0), + 'discount_per_step' => (float) ($fields['step_discount']['discount_per_step'] ?? 0), + 'max_spend' => (float) ($fields['step_discount']['max_spend'] ?? 0), ]; } diff --git a/wp-content/plugins/bookstore-core/includes/Cli/Commands.php b/wp-content/plugins/bookstore-core/includes/Cli/Commands.php index c1a0d38..2f3c88d 100644 --- a/wp-content/plugins/bookstore-core/includes/Cli/Commands.php +++ b/wp-content/plugins/bookstore-core/includes/Cli/Commands.php @@ -449,10 +449,12 @@ class Commands * Sets (or replaces) one benefit on a tier. Benefit types with real * code behind them today: free_shipping_threshold (Shipping\ * BSC_Free_Shipping, expects {"threshold": }) and - * spend_discount (Membership\MemberDiscount, expects {"min_spend": - * , "discount_amount": }). Any other type is stored - * but has no runtime effect until something reads it — see the - * class docblocks for why. + * step_discount (Membership\MemberDiscount, expects {"step_amount": + * , "discount_per_step": , "max_spend": } — + * e.g. step_amount=50/discount_per_step=5/max_spend=200 means $5 off + * per $50 spent, capped at $20 off). Any other type is stored but + * has no runtime effect until something reads it — see the class + * docblocks for why. * * ## OPTIONS * @@ -467,7 +469,7 @@ class Commands * * ## EXAMPLES * - * wp bookstore tier-set-benefit free_member spend_discount '{"min_spend":100,"discount_amount":10}' + * wp bookstore tier-set-benefit free_member step_discount '{"step_amount":50,"discount_per_step":5,"max_spend":200}' * * @subcommand tier-set-benefit */ diff --git a/wp-content/plugins/bookstore-core/includes/Membership/MemberDiscount.php b/wp-content/plugins/bookstore-core/includes/Membership/MemberDiscount.php index 5141a93..ea0b79e 100644 --- a/wp-content/plugins/bookstore-core/includes/Membership/MemberDiscount.php +++ b/wp-content/plugins/bookstore-core/includes/Membership/MemberDiscount.php @@ -5,14 +5,25 @@ namespace Bookstore\Core\Membership; defined('ABSPATH') || exit; /** - * Design doc §07 extension: "$10 off orders $100+, members only." A flat - * per-cart-subtotal discount via WC_Cart::add_fee (a negative fee), - * driven entirely by the current customer's `spend_discount` tier - * benefit — same shape as Shipping\BSC_Free_Shipping's threshold, just a - * fee instead of a shipping rate. Business case: BooksRun charges a flat - * per-order shipping fee regardless of order size, so bundling a - * customer's purchases into one $100+ order rather than several smaller - * ones saves that fee outright — more than covering the $10 discount. + * Design doc §07 extension: a stepped, sitewide promo — $5 off per $50 + * spent, capping at $200 spend for a max $20 off (evolved from an + * earlier flat "$10 off $100+, members only" version). Applied via + * WC_Cart::add_fee (a negative fee), driven entirely by the current + * customer's `step_discount` tier benefit — same shape as + * Shipping\BSC_Free_Shipping's threshold, just a fee instead of a + * shipping rate. Business case: BooksRun charges a flat per-order + * shipping fee regardless of order size, so bundling a customer's + * purchases into one larger order rather than several smaller ones + * saves that fee outright — more than covering the discount. + * + * "Sitewide" isn't a separate concept from the member-tier system — the + * `guest` tier (the universal fallback for every non-member customer; + * see Membership\Tier::tier_for_user()) carries the same `step_discount` + * benefit as `free_member` by default, so every customer gets it unless + * an admin unchecks it on one tier or the other via the settings screen + * (WooCommerce > Settings > Membership Tiers) — e.g. unchecking it only + * on `guest` turns this into a members-only promo without touching any + * code. * * Runs on woocommerce_cart_calculate_fees, which fires well before * Order\CheckoutRecheck's hooks and bakes the fee into cart/order totals @@ -30,16 +41,22 @@ class MemberDiscount public static function apply(\WC_Cart $cart): void { $user_id = get_current_user_id(); - $config = Tier::benefit_config_for_user($user_id, 'spend_discount'); - if (empty($config['min_spend']) || empty($config['discount_amount'])) { + $config = Tier::benefit_config_for_user($user_id, 'step_discount'); + if (empty($config['step_amount']) || empty($config['discount_per_step']) || empty($config['max_spend'])) { return; } + $step_amount = (float) $config['step_amount']; $subtotal = (float) $cart->get_subtotal(); - if ($subtotal < (float) $config['min_spend']) { - return; - } - $cart->add_fee('Member discount', -1 * (float) $config['discount_amount'], false); + $steps = min( + floor($subtotal / $step_amount), + floor((float) $config['max_spend'] / $step_amount) + ); + $discount = $steps * (float) $config['discount_per_step']; + + if ($discount > 0) { + $cart->add_fee('Member discount', -1 * $discount, false); + } } } diff --git a/wp-content/plugins/bookstore-core/includes/Membership/Tier.php b/wp-content/plugins/bookstore-core/includes/Membership/Tier.php index 08e4e4f..f0afff4 100644 --- a/wp-content/plugins/bookstore-core/includes/Membership/Tier.php +++ b/wp-content/plugins/bookstore-core/includes/Membership/Tier.php @@ -205,12 +205,13 @@ class Tier if (!self::find_by_slug(self::GUEST_SLUG)) { self::create(self::GUEST_SLUG, 'Guest', 0, [ 'free_shipping_threshold' => ['threshold' => 25.00], + 'step_discount' => ['step_amount' => 50, 'discount_per_step' => 5, 'max_spend' => 200], ]); } if (!self::find_by_slug(self::FREE_MEMBER_SLUG)) { self::create(self::FREE_MEMBER_SLUG, 'Free Member', 10, [ 'free_shipping_threshold' => ['threshold' => 15.00], - 'spend_discount' => ['min_spend' => 100.00, 'discount_amount' => 10.00], + 'step_discount' => ['step_amount' => 50, 'discount_per_step' => 5, 'max_spend' => 200], ]); } } diff --git a/wp-content/plugins/bookstore-core/includes/Schema.php b/wp-content/plugins/bookstore-core/includes/Schema.php index 5c73096..668fd13 100644 --- a/wp-content/plugins/bookstore-core/includes/Schema.php +++ b/wp-content/plugins/bookstore-core/includes/Schema.php @@ -174,5 +174,25 @@ class Schema } self::install(); + + // v3 -> v4: the flat "$10 off $100+" spend_discount benefit + // became a stepped, sitewide promo ($5 per $50 spent, capped at + // $200) — see Membership\MemberDiscount. A genuine product- + // default change, not a hypothetical admin customization worth + // preserving (pre-launch — see project memory on schema + // migrations), so this overwrites guest/free_member's benefits + // directly rather than merging. Post-launch, a change like this + // must target only the specific keys being replaced instead. + if ($current !== false && version_compare((string) $current, '4', '<')) { + $step_discount = ['step_amount' => 50, 'discount_per_step' => 5, 'max_spend' => 200]; + Tier::update_benefits(Tier::GUEST_SLUG, [ + 'free_shipping_threshold' => ['threshold' => 25.00], + 'step_discount' => $step_discount, + ]); + Tier::update_benefits(Tier::FREE_MEMBER_SLUG, [ + 'free_shipping_threshold' => ['threshold' => 15.00], + 'step_discount' => $step_discount, + ]); + } } } diff --git a/wp-content/plugins/bookstore-core/tests/Unit/Membership/MemberDiscountTest.php b/wp-content/plugins/bookstore-core/tests/Unit/Membership/MemberDiscountTest.php index 0e221af..cdc872e 100644 --- a/wp-content/plugins/bookstore-core/tests/Unit/Membership/MemberDiscountTest.php +++ b/wp-content/plugins/bookstore-core/tests/Unit/Membership/MemberDiscountTest.php @@ -8,11 +8,14 @@ use Brain\Monkey\Functions; use Mockery; /** - * "$10 off orders $100+, members only" (design doc §07 extension). The - * real Membership\Tier class runs unmocked — only $wpdb (underneath - * Tier's queries), get_current_user_id(), and a WC_Cart mock are - * stubbed, so these tests exercise the actual threshold/amount lookup, - * not just a canned discount value. + * The stepped, sitewide promo: $5 off per $50 spent, capped at $200 + * spend for a max $20 off (evolved from an earlier flat "$10 off $100+, + * members only" version — design doc §07 extension). "Sitewide" means + * both `guest` and `free_member` carry the same `step_discount` benefit + * by default, so a guest (user_id 0) getting the discount is the thing + * that specifically proves this isn't members-only anymore. The real + * Membership\Tier class runs unmocked — only $wpdb (underneath Tier's + * queries), get_current_user_id(), and a WC_Cart mock are stubbed. */ class MemberDiscountTest extends TestCase { @@ -23,40 +26,52 @@ class MemberDiscountTest extends TestCase parent::tearDown(); } - public function test_guest_gets_no_discount(): void - { - Functions\when('get_current_user_id')->justReturn(0); - // Guest tier has no spend_discount benefit configured. - $this->stub_wpdb_anonymous(['free_shipping_threshold' => ['threshold' => 25.00]]); - - $cart = Mockery::mock(\WC_Cart::class); - $cart->shouldReceive('get_subtotal')->andReturn('500.00'); - $cart->shouldNotReceive('add_fee'); - - MemberDiscount::apply($cart); - } - - public function test_member_below_min_spend_gets_no_discount(): void + /** + * @dataProvider subtotalCases + */ + public function test_member_gets_the_stepped_discount(float $subtotal, float $expected_discount): void { Functions\when('get_current_user_id')->justReturn(7); $this->stub_wpdb_with_member_lookup(5, [ 'free_shipping_threshold' => ['threshold' => 15.00], - 'spend_discount' => ['min_spend' => 100.00, 'discount_amount' => 10.00], + 'step_discount' => ['step_amount' => 50, 'discount_per_step' => 5, 'max_spend' => 200], ]); $cart = Mockery::mock(\WC_Cart::class); - $cart->shouldReceive('get_subtotal')->andReturn('80.00'); - $cart->shouldNotReceive('add_fee'); + $cart->shouldReceive('get_subtotal')->andReturn((string) $subtotal); + + if ($expected_discount > 0) { + $cart->shouldReceive('add_fee')->once()->with('Member discount', -1 * $expected_discount, false); + } else { + $cart->shouldNotReceive('add_fee'); + } MemberDiscount::apply($cart); } - public function test_member_at_min_spend_gets_the_discount(): void + public static function subtotalCases(): array { - Functions\when('get_current_user_id')->justReturn(7); - $this->stub_wpdb_with_member_lookup(5, [ - 'free_shipping_threshold' => ['threshold' => 15.00], - 'spend_discount' => ['min_spend' => 100.00, 'discount_amount' => 10.00], + return [ + 'nothing in cart' => [0.00, 0.00], + 'below the first step' => [30.00, 0.00], + 'exactly the first step' => [50.00, 5.00], + 'just under the second step' => [99.99, 5.00], + 'exactly the second step' => [100.00, 10.00], + 'exactly the third step' => [150.00, 15.00], + 'just under the cap' => [199.99, 15.00], + 'exactly at the cap' => [200.00, 20.00], + 'above the cap stays capped' => [250.00, 20.00], + ]; + } + + public function test_guest_gets_the_discount_too_sitewide_by_default(): void + { + Functions\when('get_current_user_id')->justReturn(0); + // guest tier carries the same step_discount benefit as + // free_member by default -- this is what makes it sitewide. + $this->stub_wpdb_anonymous([ + 'free_shipping_threshold' => ['threshold' => 25.00], + 'step_discount' => ['step_amount' => 50, 'discount_per_step' => 5, 'max_spend' => 200], ]); $cart = Mockery::mock(\WC_Cart::class); @@ -66,16 +81,19 @@ class MemberDiscountTest extends TestCase MemberDiscount::apply($cart); } - public function test_member_above_min_spend_gets_the_discount(): void + public function test_guest_gets_nothing_when_admin_disables_it_on_the_guest_tier(): void { - Functions\when('get_current_user_id')->justReturn(7); - $this->stub_wpdb_with_member_lookup(5, [ - 'spend_discount' => ['min_spend' => 100.00, 'discount_amount' => 10.00], + Functions\when('get_current_user_id')->justReturn(0); + // Admin unchecked step_discount on the guest row via the + // settings screen -- members-only mode, proven by the absence + // of the benefit key here (not a hardcoded "guest excluded"). + $this->stub_wpdb_anonymous([ + 'free_shipping_threshold' => ['threshold' => 25.00], ]); $cart = Mockery::mock(\WC_Cart::class); - $cart->shouldReceive('get_subtotal')->andReturn('142.50'); - $cart->shouldReceive('add_fee')->once()->with('Member discount', -10.00, false); + $cart->shouldReceive('get_subtotal')->andReturn('250.00'); + $cart->shouldNotReceive('add_fee'); MemberDiscount::apply($cart); }