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
+122 -62

No files matched your search

@@ -58,7 +58,7 @@ class TierSettings extends \WC_Settings_Page
</tr>
</thead>
<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>
<td>
<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'); ?>" />
</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>
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'); ?>" />
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'); ?>" />+
<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>
$<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'); ?>" />
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>
<?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),
];
}
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),
];
}
@@ -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": <number>}) and
* spend_discount (Membership\MemberDiscount, expects {"min_spend":
* <number>, "discount_amount": <number>}). 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":
* <number>, "discount_per_step": <number>, "max_spend": <number>} —
* 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
*/
@@ -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);
}
}
}
@@ -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],
]);
}
}
@@ -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,
]);
}
}
}