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:
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);
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in new issue
Block a user