@@ -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);
}