Evolve the member discount into a sitewide stepped promo

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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MqhgnCRjjVLFnZ3Jpn93Vg
This commit is contained in:
twooeyandClaude Sonnet 5 committed 2026-08-28 12:20:18 -04:00
1 parent 13e7362579
commit 773883a0f3
7 files changed
+121 -61

No files matched your search

@@ -13,7 +13,7 @@ defined('ABSPATH') || exit;
define('BSC_PLUGIN_FILE', __FILE__); define('BSC_PLUGIN_FILE', __FILE__);
define('BSC_PLUGIN_DIR', plugin_dir_path(__FILE__)); define('BSC_PLUGIN_DIR', plugin_dir_path(__FILE__));
define('BSC_VERSION', '0.2.0'); define('BSC_VERSION', '0.2.0');
define('BSC_SCHEMA_VERSION', '3'); define('BSC_SCHEMA_VERSION', '4');
spl_autoload_register(function (string $class) { spl_autoload_register(function (string $class) {
$prefix = 'Bookstore\\Core\\'; $prefix = 'Bookstore\\Core\\';
@@ -58,7 +58,7 @@ class TierSettings extends \WC_Settings_Page
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
<?php foreach ($tiers as $tier): $fs = $tier['benefits']['free_shipping_threshold'] ?? null; $sd = $tier['benefits']['spend_discount'] ?? null; ?> <?php foreach ($tiers as $tier): $fs = $tier['benefits']['free_shipping_threshold'] ?? null; $sd = $tier['benefits']['step_discount'] ?? null; ?>
<tr> <tr>
<td> <td>
<input type="text" name="bsc_tier[<?php echo esc_attr($tier['tier_id']); ?>][name]" value="<?php echo esc_attr($tier['name']); ?>" /> <input type="text" name="bsc_tier[<?php echo esc_attr($tier['tier_id']); ?>][name]" value="<?php echo esc_attr($tier['name']); ?>" />
@@ -72,9 +72,10 @@ class TierSettings extends \WC_Settings_Page
$<input type="number" step="0.01" style="width:6em" name="bsc_tier[<?php echo esc_attr($tier['tier_id']); ?>][free_shipping_threshold][threshold]" value="<?php echo esc_attr($fs['threshold'] ?? '0.00'); ?>" /> $<input type="number" step="0.01" style="width:6em" name="bsc_tier[<?php echo esc_attr($tier['tier_id']); ?>][free_shipping_threshold][threshold]" value="<?php echo esc_attr($fs['threshold'] ?? '0.00'); ?>" />
</td> </td>
<td> <td>
<label><input type="checkbox" name="bsc_tier[<?php echo esc_attr($tier['tier_id']); ?>][spend_discount][enabled]" value="1" <?php checked($sd !== null); ?> /> enabled</label><br> <label><input type="checkbox" name="bsc_tier[<?php echo esc_attr($tier['tier_id']); ?>][step_discount][enabled]" value="1" <?php checked($sd !== null); ?> /> enabled</label><br>
save $<input type="number" step="0.01" style="width:6em" name="bsc_tier[<?php echo esc_attr($tier['tier_id']); ?>][spend_discount][discount_amount]" value="<?php echo esc_attr($sd['discount_amount'] ?? '0.00'); ?>" /> $<input type="number" step="0.01" style="width:5em" name="bsc_tier[<?php echo esc_attr($tier['tier_id']); ?>][step_discount][discount_per_step]" value="<?php echo esc_attr($sd['discount_per_step'] ?? '0.00'); ?>" />
on orders $<input type="number" step="0.01" style="width:6em" name="bsc_tier[<?php echo esc_attr($tier['tier_id']); ?>][spend_discount][min_spend]" value="<?php echo esc_attr($sd['min_spend'] ?? '0.00'); ?>" />+ off per $<input type="number" step="0.01" style="width:5em" name="bsc_tier[<?php echo esc_attr($tier['tier_id']); ?>][step_discount][step_amount]" value="<?php echo esc_attr($sd['step_amount'] ?? '0.00'); ?>" />
spent, capped at $<input type="number" step="0.01" style="width:6em" name="bsc_tier[<?php echo esc_attr($tier['tier_id']); ?>][step_discount][max_spend]" value="<?php echo esc_attr($sd['max_spend'] ?? '0.00'); ?>" />
</td> </td>
<td> <td>
<?php if ($tier['slug'] !== Tier::GUEST_SLUG): ?> <?php if ($tier['slug'] !== Tier::GUEST_SLUG): ?>
@@ -125,10 +126,11 @@ class TierSettings extends \WC_Settings_Page
'threshold' => (float) ($fields['free_shipping_threshold']['threshold'] ?? 0), 'threshold' => (float) ($fields['free_shipping_threshold']['threshold'] ?? 0),
]; ];
} }
if (!empty($fields['spend_discount']['enabled'])) { if (!empty($fields['step_discount']['enabled'])) {
$benefits['spend_discount'] = [ $benefits['step_discount'] = [
'min_spend' => (float) ($fields['spend_discount']['min_spend'] ?? 0), 'step_amount' => (float) ($fields['step_discount']['step_amount'] ?? 0),
'discount_amount' => (float) ($fields['spend_discount']['discount_amount'] ?? 0), 'discount_per_step' => (float) ($fields['step_discount']['discount_per_step'] ?? 0),
'max_spend' => (float) ($fields['step_discount']['max_spend'] ?? 0),
]; ];
} }
@@ -449,10 +449,12 @@ class Commands
* Sets (or replaces) one benefit on a tier. Benefit types with real * Sets (or replaces) one benefit on a tier. Benefit types with real
* code behind them today: free_shipping_threshold (Shipping\ * code behind them today: free_shipping_threshold (Shipping\
* BSC_Free_Shipping, expects {"threshold": <number>}) and * BSC_Free_Shipping, expects {"threshold": <number>}) and
* spend_discount (Membership\MemberDiscount, expects {"min_spend": * step_discount (Membership\MemberDiscount, expects {"step_amount":
* <number>, "discount_amount": <number>}). Any other type is stored * <number>, "discount_per_step": <number>, "max_spend": <number>} —
* but has no runtime effect until something reads it — see the * e.g. step_amount=50/discount_per_step=5/max_spend=200 means $5 off
* class docblocks for why. * 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 * ## OPTIONS
* *
@@ -467,7 +469,7 @@ class Commands
* *
* ## EXAMPLES * ## 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 * @subcommand tier-set-benefit
*/ */
@@ -5,14 +5,25 @@ namespace Bookstore\Core\Membership;
defined('ABSPATH') || exit; defined('ABSPATH') || exit;
/** /**
* Design doc §07 extension: "$10 off orders $100+, members only." A flat * Design doc §07 extension: a stepped, sitewide promo — $5 off per $50
* per-cart-subtotal discount via WC_Cart::add_fee (a negative fee), * spent, capping at $200 spend for a max $20 off (evolved from an
* driven entirely by the current customer's `spend_discount` tier * earlier flat "$10 off $100+, members only" version). Applied via
* benefit — same shape as Shipping\BSC_Free_Shipping's threshold, just a * WC_Cart::add_fee (a negative fee), driven entirely by the current
* fee instead of a shipping rate. Business case: BooksRun charges a flat * customer's `step_discount` tier benefit — same shape as
* per-order shipping fee regardless of order size, so bundling a * Shipping\BSC_Free_Shipping's threshold, just a fee instead of a
* customer's purchases into one $100+ order rather than several smaller * shipping rate. Business case: BooksRun charges a flat per-order
* ones saves that fee outright — more than covering the $10 discount. * 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 * Runs on woocommerce_cart_calculate_fees, which fires well before
* Order\CheckoutRecheck's hooks and bakes the fee into cart/order totals * 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 public static function apply(\WC_Cart $cart): void
{ {
$user_id = get_current_user_id(); $user_id = get_current_user_id();
$config = Tier::benefit_config_for_user($user_id, 'spend_discount'); $config = Tier::benefit_config_for_user($user_id, 'step_discount');
if (empty($config['min_spend']) || empty($config['discount_amount'])) { if (empty($config['step_amount']) || empty($config['discount_per_step']) || empty($config['max_spend'])) {
return; return;
} }
$step_amount = (float) $config['step_amount'];
$subtotal = (float) $cart->get_subtotal(); $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);
}
} }
} }
@@ -205,12 +205,13 @@ class Tier
if (!self::find_by_slug(self::GUEST_SLUG)) { if (!self::find_by_slug(self::GUEST_SLUG)) {
self::create(self::GUEST_SLUG, 'Guest', 0, [ self::create(self::GUEST_SLUG, 'Guest', 0, [
'free_shipping_threshold' => ['threshold' => 25.00], '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)) { if (!self::find_by_slug(self::FREE_MEMBER_SLUG)) {
self::create(self::FREE_MEMBER_SLUG, 'Free Member', 10, [ self::create(self::FREE_MEMBER_SLUG, 'Free Member', 10, [
'free_shipping_threshold' => ['threshold' => 15.00], '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],
]); ]);
} }
} }
@@ -174,5 +174,25 @@ class Schema
} }
self::install(); 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,
]);
}
} }
} }
@@ -8,11 +8,14 @@ use Brain\Monkey\Functions;
use Mockery; use Mockery;
/** /**
* "$10 off orders $100+, members only" (design doc §07 extension). The * The stepped, sitewide promo: $5 off per $50 spent, capped at $200
* real Membership\Tier class runs unmocked — only $wpdb (underneath * spend for a max $20 off (evolved from an earlier flat "$10 off $100+,
* Tier's queries), get_current_user_id(), and a WC_Cart mock are * members only" version — design doc §07 extension). "Sitewide" means
* stubbed, so these tests exercise the actual threshold/amount lookup, * both `guest` and `free_member` carry the same `step_discount` benefit
* not just a canned discount value. * 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 class MemberDiscountTest extends TestCase
{ {
@@ -23,40 +26,52 @@ class MemberDiscountTest extends TestCase
parent::tearDown(); parent::tearDown();
} }
public function test_guest_gets_no_discount(): void /**
{ * @dataProvider subtotalCases
Functions\when('get_current_user_id')->justReturn(0); */
// Guest tier has no spend_discount benefit configured. public function test_member_gets_the_stepped_discount(float $subtotal, float $expected_discount): void
$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
{ {
Functions\when('get_current_user_id')->justReturn(7); Functions\when('get_current_user_id')->justReturn(7);
$this->stub_wpdb_with_member_lookup(5, [ $this->stub_wpdb_with_member_lookup(5, [
'free_shipping_threshold' => ['threshold' => 15.00], '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 = Mockery::mock(\WC_Cart::class);
$cart->shouldReceive('get_subtotal')->andReturn('80.00'); $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'); $cart->shouldNotReceive('add_fee');
}
MemberDiscount::apply($cart); 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); return [
$this->stub_wpdb_with_member_lookup(5, [ 'nothing in cart' => [0.00, 0.00],
'free_shipping_threshold' => ['threshold' => 15.00], 'below the first step' => [30.00, 0.00],
'spend_discount' => ['min_spend' => 100.00, 'discount_amount' => 10.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); $cart = Mockery::mock(\WC_Cart::class);
@@ -66,16 +81,19 @@ class MemberDiscountTest extends TestCase
MemberDiscount::apply($cart); 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); Functions\when('get_current_user_id')->justReturn(0);
$this->stub_wpdb_with_member_lookup(5, [ // Admin unchecked step_discount on the guest row via the
'spend_discount' => ['min_spend' => 100.00, 'discount_amount' => 10.00], // 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 = Mockery::mock(\WC_Cart::class);
$cart->shouldReceive('get_subtotal')->andReturn('142.50'); $cart->shouldReceive('get_subtotal')->andReturn('250.00');
$cart->shouldReceive('add_fee')->once()->with('Member discount', -10.00, false); $cart->shouldNotReceive('add_fee');
MemberDiscount::apply($cart); MemberDiscount::apply($cart);
} }