Replace the single is_free_member flag with a real member-tier system
Design doc §07 extension: admin-configurable tiers, each with an arbitrary JSON benefit set, instead of one hardcoded boolean. Ships two benefit types on top of the new Membership\Tier lookup: the existing free-shipping threshold (refactored, not just left on the old flag) and a new "$10 off orders $100+, members only" cart discount — bundling purchases into one $100+ order saves BooksRun's flat per-order shipping fee outright, more than covering the discount. Researched official/well-supported WooCommerce plugins first (Memberships, Points and Rewards, Smart Coupons) — none combine tiers with configurable per-tier benefit sets, so building custom on the existing wp_bsc_member table was the right call. Registration is enabled via WooCommerce's own native My Account signup (deploy.sh) rather than a bespoke form, with a marketing-consent checkbox added via the standard extension hooks. Admin management: new `wp bookstore tier-*` CLI commands plus a WooCommerce Settings screen (this plugin's first admin UI). Migration drops and recreates bsc_member rather than backfilling — pre-launch, only synthetic data exists anywhere. Live-verified beyond the unit suite: real migration run, CLI commands end-to-end, a real registration submitted through the actual WC form (with and without consent), and the settings screen edited/created/ deleted tiers through real authenticated form POSTs with real nonces. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01MqhgnCRjjVLFnZ3Jpn93Vg
This commit is contained in:
1 parent
7023044278
commit
13e7362579
13 files changed
+930
-43
No files matched your search
@@ -129,6 +129,12 @@ echo "==> enabling WooCommerce HPOS (custom order tables)"
|
|||||||
$WP option update woocommerce_custom_orders_table_enabled yes
|
$WP option update woocommerce_custom_orders_table_enabled yes
|
||||||
$WP option update woocommerce_custom_orders_table_data_sync_enabled yes
|
$WP option update woocommerce_custom_orders_table_data_sync_enabled yes
|
||||||
|
|
||||||
|
# Membership\Registration hooks onto WooCommerce's own My Account
|
||||||
|
# registration form (design doc §07) rather than a bespoke signup form —
|
||||||
|
# it's disabled by default, so nothing shows up without this.
|
||||||
|
echo "==> enabling WooCommerce account registration (member signup)"
|
||||||
|
$WP option update woocommerce_enable_myaccount_registration yes
|
||||||
|
|
||||||
# WooCommerce's default (unset -> '1:1') force-crops every product
|
# WooCommerce's default (unset -> '1:1') force-crops every product
|
||||||
# thumbnail to a square — confirmed directly: a 335x500 portrait book cover
|
# thumbnail to a square — confirmed directly: a 335x500 portrait book cover
|
||||||
# was landing at a hard-cropped 300x300 shop-grid thumbnail, cutting off
|
# was landing at a hard-cropped 300x300 shop-grid thumbnail, cutting off
|
||||||
|
|||||||
@@ -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', '2');
|
define('BSC_SCHEMA_VERSION', '3');
|
||||||
|
|
||||||
spl_autoload_register(function (string $class) {
|
spl_autoload_register(function (string $class) {
|
||||||
$prefix = 'Bookstore\\Core\\';
|
$prefix = 'Bookstore\\Core\\';
|
||||||
@@ -59,6 +59,15 @@ add_filter('woocommerce_shipping_methods', function (array $methods) {
|
|||||||
return $methods;
|
return $methods;
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Membership tiers/benefits: design doc §07 extension (see includes/Membership/).
|
||||||
|
\Bookstore\Core\Membership\MemberDiscount::register();
|
||||||
|
\Bookstore\Core\Membership\Registration::register();
|
||||||
|
|
||||||
|
add_filter('woocommerce_get_settings_pages', function (array $settings) {
|
||||||
|
$settings[] = new \Bookstore\Core\Admin\TierSettings();
|
||||||
|
return $settings;
|
||||||
|
});
|
||||||
|
|
||||||
add_action('init', function () {
|
add_action('init', function () {
|
||||||
\Bookstore\Core\Taxonomy\TagTaxonomies::register();
|
\Bookstore\Core\Taxonomy\TagTaxonomies::register();
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -0,0 +1,158 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace Bookstore\Core\Admin;
|
||||||
|
|
||||||
|
use Bookstore\Core\Membership\Tier;
|
||||||
|
|
||||||
|
defined('ABSPATH') || exit;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* First admin-settings screen this plugin has ever needed (confirmed: no
|
||||||
|
* WC_Settings_Page/add_menu_page precedent existed before this) —
|
||||||
|
* WooCommerce > Settings > Membership Tiers. WooCommerce's Settings API
|
||||||
|
* has no built-in repeater field for a dynamic tier/benefit list, so
|
||||||
|
* rather than fighting it, output()/save() are fully overridden with
|
||||||
|
* plain hand-rolled markup/POST handling instead of the usual
|
||||||
|
* get_settings()-array approach — WC_Admin_Settings still wraps our tab
|
||||||
|
* in its own <form> with a nonce (verified before woocommerce_settings_
|
||||||
|
* save_{id} fires), so no separate nonce handling is needed here.
|
||||||
|
*
|
||||||
|
* Only the two benefit types that currently have real code behind them
|
||||||
|
* (Shipping\BSC_Free_Shipping, Membership\MemberDiscount) get dedicated
|
||||||
|
* fields — a third benefit type needs one more fields block here, same
|
||||||
|
* as it needs a small new class at whatever hook enforces it.
|
||||||
|
*/
|
||||||
|
class TierSettings extends \WC_Settings_Page
|
||||||
|
{
|
||||||
|
public function __construct()
|
||||||
|
{
|
||||||
|
$this->id = 'bsc_tiers';
|
||||||
|
$this->label = 'Membership Tiers';
|
||||||
|
parent::__construct();
|
||||||
|
}
|
||||||
|
|
||||||
|
public function output(): void
|
||||||
|
{
|
||||||
|
if (!current_user_can('manage_woocommerce')) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$errors = get_transient('bsc_tier_settings_errors') ?: [];
|
||||||
|
delete_transient('bsc_tier_settings_errors');
|
||||||
|
foreach ($errors as $error) {
|
||||||
|
echo '<div class="notice notice-error"><p>' . esc_html($error) . '</p></div>';
|
||||||
|
}
|
||||||
|
|
||||||
|
$tiers = Tier::all();
|
||||||
|
?>
|
||||||
|
<h2>Membership Tiers</h2>
|
||||||
|
<p>Each tier's benefits apply automatically to whoever is assigned to it — see <code>wp bookstore tier assign <user> <slug></code> for assigning members from the command line.</p>
|
||||||
|
<table class="widefat" style="max-width: 900px;">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Name / slug</th>
|
||||||
|
<th>Sort order</th>
|
||||||
|
<th>Free shipping threshold</th>
|
||||||
|
<th>Member discount</th>
|
||||||
|
<th></th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<?php foreach ($tiers as $tier): $fs = $tier['benefits']['free_shipping_threshold'] ?? null; $sd = $tier['benefits']['spend_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']); ?>" />
|
||||||
|
<br><code><?php echo esc_html($tier['slug']); ?></code>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<input type="number" style="width:5em" name="bsc_tier[<?php echo esc_attr($tier['tier_id']); ?>][sort_order]" value="<?php echo esc_attr($tier['sort_order']); ?>" />
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<label><input type="checkbox" name="bsc_tier[<?php echo esc_attr($tier['tier_id']); ?>][free_shipping_threshold][enabled]" value="1" <?php checked($fs !== null); ?> /> enabled</label><br>
|
||||||
|
$<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'); ?>" />+
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<?php if ($tier['slug'] !== Tier::GUEST_SLUG): ?>
|
||||||
|
<label><input type="checkbox" name="bsc_tier[<?php echo esc_attr($tier['tier_id']); ?>][delete]" value="1" /> delete</label>
|
||||||
|
<?php endif; ?>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
<?php endforeach; ?>
|
||||||
|
<tr>
|
||||||
|
<td><input type="text" name="bsc_new_tier[slug]" placeholder="slug (e.g. gold)" /><br><input type="text" name="bsc_new_tier[name]" placeholder="Display name" /></td>
|
||||||
|
<td><input type="number" style="width:5em" name="bsc_new_tier[sort_order]" value="<?php echo esc_attr((count($tiers)) * 10); ?>" /></td>
|
||||||
|
<td colspan="2"><em>New tier — leave benefit fields on existing rows for now, add them after saving.</em></td>
|
||||||
|
<td></td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
<?php
|
||||||
|
}
|
||||||
|
|
||||||
|
public function save(): void
|
||||||
|
{
|
||||||
|
if (!current_user_can('manage_woocommerce')) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$errors = [];
|
||||||
|
|
||||||
|
$posted_tiers = $_POST['bsc_tier'] ?? [];
|
||||||
|
if (is_array($posted_tiers)) {
|
||||||
|
foreach ($posted_tiers as $tier_id => $fields) {
|
||||||
|
$tier_id = (int) $tier_id;
|
||||||
|
$tier = Tier::find($tier_id);
|
||||||
|
if (!$tier) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!empty($fields['delete'])) {
|
||||||
|
$result = Tier::delete($tier['slug']);
|
||||||
|
if ($result !== true) {
|
||||||
|
$errors[] = "Could not delete '{$tier['name']}': {$result}";
|
||||||
|
}
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$benefits = [];
|
||||||
|
if (!empty($fields['free_shipping_threshold']['enabled'])) {
|
||||||
|
$benefits['free_shipping_threshold'] = [
|
||||||
|
'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),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
Tier::update(
|
||||||
|
$tier_id,
|
||||||
|
sanitize_text_field($fields['name'] ?? $tier['name']),
|
||||||
|
(int) ($fields['sort_order'] ?? $tier['sort_order']),
|
||||||
|
$benefits
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$new_tier = $_POST['bsc_new_tier'] ?? [];
|
||||||
|
if (!empty($new_tier['slug']) && !empty($new_tier['name'])) {
|
||||||
|
$slug = sanitize_title($new_tier['slug']);
|
||||||
|
if (Tier::find_by_slug($slug)) {
|
||||||
|
$errors[] = "A tier with slug '{$slug}' already exists.";
|
||||||
|
} else {
|
||||||
|
Tier::create($slug, sanitize_text_field($new_tier['name']), (int) ($new_tier['sort_order'] ?? 0));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($errors) {
|
||||||
|
set_transient('bsc_tier_settings_errors', $errors, 60);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -10,6 +10,7 @@ use Bookstore\Core\Import\BooksRunOfferSync;
|
|||||||
use Bookstore\Core\Import\GutenbergImporter;
|
use Bookstore\Core\Import\GutenbergImporter;
|
||||||
use Bookstore\Core\Import\SyntheticOfferGenerator;
|
use Bookstore\Core\Import\SyntheticOfferGenerator;
|
||||||
use Bookstore\Core\Integration\HardcoverAdapter;
|
use Bookstore\Core\Integration\HardcoverAdapter;
|
||||||
|
use Bookstore\Core\Membership\Tier;
|
||||||
use Bookstore\Core\Product\CoverSync;
|
use Bookstore\Core\Product\CoverSync;
|
||||||
use Bookstore\Core\Product\ProductSync;
|
use Bookstore\Core\Product\ProductSync;
|
||||||
use Bookstore\Core\Taxonomy\TagTaxonomies;
|
use Bookstore\Core\Taxonomy\TagTaxonomies;
|
||||||
@@ -389,4 +390,180 @@ class Commands
|
|||||||
$result['processed']
|
$result['processed']
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Lists membership tiers and their configured benefits.
|
||||||
|
*
|
||||||
|
* ## EXAMPLES
|
||||||
|
*
|
||||||
|
* wp bookstore tier-list
|
||||||
|
*
|
||||||
|
* @subcommand tier-list
|
||||||
|
*/
|
||||||
|
public function tier_list($args, $assoc_args)
|
||||||
|
{
|
||||||
|
$items = array_map(function (array $tier) {
|
||||||
|
return [
|
||||||
|
'slug' => $tier['slug'],
|
||||||
|
'name' => $tier['name'],
|
||||||
|
'sort_order' => $tier['sort_order'],
|
||||||
|
'benefits' => wp_json_encode($tier['benefits']),
|
||||||
|
];
|
||||||
|
}, Tier::all());
|
||||||
|
|
||||||
|
\WP_CLI\Utils\format_items('table', $items, ['slug', 'name', 'sort_order', 'benefits']);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Creates a new, benefit-less membership tier — add benefits with
|
||||||
|
* tier-set-benefit afterward.
|
||||||
|
*
|
||||||
|
* ## OPTIONS
|
||||||
|
*
|
||||||
|
* <slug>
|
||||||
|
* : Unique slug (e.g. gold).
|
||||||
|
*
|
||||||
|
* <name>
|
||||||
|
* : Display name.
|
||||||
|
*
|
||||||
|
* [--sort-order=<number>]
|
||||||
|
* : Display order (default 0).
|
||||||
|
*
|
||||||
|
* ## EXAMPLES
|
||||||
|
*
|
||||||
|
* wp bookstore tier-create gold "Gold Member" --sort-order=20
|
||||||
|
*
|
||||||
|
* @subcommand tier-create
|
||||||
|
*/
|
||||||
|
public function tier_create($args, $assoc_args)
|
||||||
|
{
|
||||||
|
[$slug, $name] = $args;
|
||||||
|
if (Tier::find_by_slug($slug)) {
|
||||||
|
\WP_CLI::error("A tier with slug '{$slug}' already exists.");
|
||||||
|
}
|
||||||
|
Tier::create($slug, $name, (int) ($assoc_args['sort-order'] ?? 0));
|
||||||
|
\WP_CLI::success("Created tier '{$slug}'.");
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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.
|
||||||
|
*
|
||||||
|
* ## OPTIONS
|
||||||
|
*
|
||||||
|
* <slug>
|
||||||
|
* : Tier slug.
|
||||||
|
*
|
||||||
|
* <type>
|
||||||
|
* : Benefit type key.
|
||||||
|
*
|
||||||
|
* <json-config>
|
||||||
|
* : Benefit config as a JSON object.
|
||||||
|
*
|
||||||
|
* ## EXAMPLES
|
||||||
|
*
|
||||||
|
* wp bookstore tier-set-benefit free_member spend_discount '{"min_spend":100,"discount_amount":10}'
|
||||||
|
*
|
||||||
|
* @subcommand tier-set-benefit
|
||||||
|
*/
|
||||||
|
public function tier_set_benefit($args, $assoc_args)
|
||||||
|
{
|
||||||
|
[$slug, $type, $json_config] = $args;
|
||||||
|
$config = json_decode($json_config, true);
|
||||||
|
if (!is_array($config)) {
|
||||||
|
\WP_CLI::error('json-config must be a valid JSON object.');
|
||||||
|
}
|
||||||
|
if (!Tier::set_benefit($slug, $type, $config)) {
|
||||||
|
\WP_CLI::error("No such tier '{$slug}'.");
|
||||||
|
}
|
||||||
|
\WP_CLI::success("Set '{$type}' on tier '{$slug}'.");
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Removes one benefit from a tier.
|
||||||
|
*
|
||||||
|
* ## OPTIONS
|
||||||
|
*
|
||||||
|
* <slug>
|
||||||
|
* : Tier slug.
|
||||||
|
*
|
||||||
|
* <type>
|
||||||
|
* : Benefit type key to remove.
|
||||||
|
*
|
||||||
|
* ## EXAMPLES
|
||||||
|
*
|
||||||
|
* wp bookstore tier-remove-benefit free_member spend_discount
|
||||||
|
*
|
||||||
|
* @subcommand tier-remove-benefit
|
||||||
|
*/
|
||||||
|
public function tier_remove_benefit($args, $assoc_args)
|
||||||
|
{
|
||||||
|
[$slug, $type] = $args;
|
||||||
|
if (!Tier::remove_benefit($slug, $type)) {
|
||||||
|
\WP_CLI::error("No such tier '{$slug}'.");
|
||||||
|
}
|
||||||
|
\WP_CLI::success("Removed '{$type}' from tier '{$slug}'.");
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Assigns a user to a tier.
|
||||||
|
*
|
||||||
|
* ## OPTIONS
|
||||||
|
*
|
||||||
|
* <user>
|
||||||
|
* : User ID, login, or email.
|
||||||
|
*
|
||||||
|
* <slug>
|
||||||
|
* : Tier slug to assign.
|
||||||
|
*
|
||||||
|
* ## EXAMPLES
|
||||||
|
*
|
||||||
|
* wp bookstore tier-assign customer@example.com free_member
|
||||||
|
*
|
||||||
|
* @subcommand tier-assign
|
||||||
|
*/
|
||||||
|
public function tier_assign($args, $assoc_args)
|
||||||
|
{
|
||||||
|
[$user_identifier, $slug] = $args;
|
||||||
|
$user = is_numeric($user_identifier)
|
||||||
|
? get_user_by('id', (int) $user_identifier)
|
||||||
|
: (get_user_by('email', $user_identifier) ?: get_user_by('login', $user_identifier));
|
||||||
|
if (!$user) {
|
||||||
|
\WP_CLI::error("No such user '{$user_identifier}'.");
|
||||||
|
}
|
||||||
|
if (!Tier::assign_member($user->ID, $slug)) {
|
||||||
|
\WP_CLI::error("No such tier '{$slug}'.");
|
||||||
|
}
|
||||||
|
\WP_CLI::success("Assigned user #{$user->ID} ({$user->user_email}) to tier '{$slug}'.");
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Deletes a tier. Refuses if it's the guest tier (the universal
|
||||||
|
* fallback) or if any member is still assigned to it.
|
||||||
|
*
|
||||||
|
* ## OPTIONS
|
||||||
|
*
|
||||||
|
* <slug>
|
||||||
|
* : Tier slug to delete.
|
||||||
|
*
|
||||||
|
* ## EXAMPLES
|
||||||
|
*
|
||||||
|
* wp bookstore tier-delete gold
|
||||||
|
*
|
||||||
|
* @subcommand tier-delete
|
||||||
|
*/
|
||||||
|
public function tier_delete($args, $assoc_args)
|
||||||
|
{
|
||||||
|
[$slug] = $args;
|
||||||
|
$result = Tier::delete($slug);
|
||||||
|
if ($result !== true) {
|
||||||
|
\WP_CLI::error((string) $result);
|
||||||
|
}
|
||||||
|
\WP_CLI::success("Deleted tier '{$slug}'.");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,45 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
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.
|
||||||
|
*
|
||||||
|
* Runs on woocommerce_cart_calculate_fees, which fires well before
|
||||||
|
* Order\CheckoutRecheck's hooks and bakes the fee into cart/order totals
|
||||||
|
* before CheckoutRecheck ever runs — confirmed non-conflicting:
|
||||||
|
* CheckoutRecheck only re-validates per-line supplier pricing, it never
|
||||||
|
* inspects or overwrites fees.
|
||||||
|
*/
|
||||||
|
class MemberDiscount
|
||||||
|
{
|
||||||
|
public static function register(): void
|
||||||
|
{
|
||||||
|
add_action('woocommerce_cart_calculate_fees', [self::class, 'apply']);
|
||||||
|
}
|
||||||
|
|
||||||
|
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'])) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$subtotal = (float) $cart->get_subtotal();
|
||||||
|
if ($subtotal < (float) $config['min_spend']) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$cart->add_fee('Member discount', -1 * (float) $config['discount_amount'], false);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,52 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace Bookstore\Core\Membership;
|
||||||
|
|
||||||
|
defined('ABSPATH') || exit;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Design doc §07: "free membership... verified member/account flag plus
|
||||||
|
* transparent marketing-email consent/preferences." Built on WooCommerce's
|
||||||
|
* own My Account registration (enabled via woocommerce_enable_myaccount_
|
||||||
|
* registration in deploy.sh) rather than a bespoke signup form — the
|
||||||
|
* standard WC extension points for adding a custom registration field
|
||||||
|
* (woocommerce_register_form to render it, woocommerce_created_customer to
|
||||||
|
* persist it, firing only after WC's own registration nonce/validation has
|
||||||
|
* already passed) are the official way to do this, so nothing homegrown
|
||||||
|
* exists here beyond the consent checkbox itself and assigning the new
|
||||||
|
* account to the free_member tier.
|
||||||
|
*/
|
||||||
|
class Registration
|
||||||
|
{
|
||||||
|
public static function register(): void
|
||||||
|
{
|
||||||
|
add_action('woocommerce_register_form', [self::class, 'render_consent_field']);
|
||||||
|
add_action('woocommerce_created_customer', [self::class, 'on_customer_created'], 10, 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function render_consent_field(): void
|
||||||
|
{
|
||||||
|
?>
|
||||||
|
<p class="form-row form-row-wide">
|
||||||
|
<label for="bsc_marketing_consent">
|
||||||
|
<input type="checkbox" class="woocommerce-Input woocommerce-Input--checkbox input-checkbox" name="bsc_marketing_consent" id="bsc_marketing_consent" value="1" />
|
||||||
|
<?php esc_html_e('Sign me up for free membership (free shipping at $15, plus member-only order discounts) and occasional email updates.', 'bookstore-core'); ?>
|
||||||
|
</label>
|
||||||
|
</p>
|
||||||
|
<?php
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function on_customer_created(int $customer_id): void
|
||||||
|
{
|
||||||
|
$consent = !empty($_POST['bsc_marketing_consent']) ? 1 : 0;
|
||||||
|
$tier = Tier::find_by_slug(Tier::FREE_MEMBER_SLUG);
|
||||||
|
|
||||||
|
global $wpdb;
|
||||||
|
$wpdb->insert($wpdb->prefix . 'bsc_member', [
|
||||||
|
'user_id' => $customer_id,
|
||||||
|
'tier_id' => $tier['tier_id'] ?? null,
|
||||||
|
'marketing_consent' => $consent,
|
||||||
|
'consent_captured_at' => current_time('mysql'),
|
||||||
|
], ['%d', '%d', '%d', '%s']);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,225 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace Bookstore\Core\Membership;
|
||||||
|
|
||||||
|
defined('ABSPATH') || exit;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Design doc §07's single is_free_member flag became a real tier system:
|
||||||
|
* bsc_tier holds an arbitrary JSON `benefits` map per tier (see
|
||||||
|
* Schema.php), so adding a tier, or changing an existing tier's benefit
|
||||||
|
* *values*, is a pure data change — no migration, no deploy. Adding a
|
||||||
|
* wholly new benefit *type* (one nothing currently checks for) still
|
||||||
|
* needs a small class at the point that enforces it, same as
|
||||||
|
* Shipping\BSC_Free_Shipping/MemberDiscount are — this is the single
|
||||||
|
* source of truth both read from, replacing BSC_Free_Shipping's old
|
||||||
|
* private duplicated is_free_member() query.
|
||||||
|
*/
|
||||||
|
class Tier
|
||||||
|
{
|
||||||
|
public const GUEST_SLUG = 'guest';
|
||||||
|
public const FREE_MEMBER_SLUG = 'free_member';
|
||||||
|
|
||||||
|
/** @return array<int, array{tier_id:int, slug:string, name:string, sort_order:int, benefits:array}> */
|
||||||
|
public static function all(): array
|
||||||
|
{
|
||||||
|
global $wpdb;
|
||||||
|
$rows = $wpdb->get_results(
|
||||||
|
"SELECT tier_id, slug, name, sort_order, benefits FROM {$wpdb->prefix}bsc_tier ORDER BY sort_order ASC",
|
||||||
|
ARRAY_A
|
||||||
|
);
|
||||||
|
return array_map([self::class, 'hydrate'], $rows ?: []);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function find(int $tier_id): ?array
|
||||||
|
{
|
||||||
|
global $wpdb;
|
||||||
|
$row = $wpdb->get_row($wpdb->prepare(
|
||||||
|
"SELECT tier_id, slug, name, sort_order, benefits FROM {$wpdb->prefix}bsc_tier WHERE tier_id = %d",
|
||||||
|
$tier_id
|
||||||
|
), ARRAY_A);
|
||||||
|
return $row ? self::hydrate($row) : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function find_by_slug(string $slug): ?array
|
||||||
|
{
|
||||||
|
global $wpdb;
|
||||||
|
$row = $wpdb->get_row($wpdb->prepare(
|
||||||
|
"SELECT tier_id, slug, name, sort_order, benefits FROM {$wpdb->prefix}bsc_tier WHERE slug = %s",
|
||||||
|
$slug
|
||||||
|
), ARRAY_A);
|
||||||
|
return $row ? self::hydrate($row) : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function create(string $slug, string $name, int $sort_order = 0, array $benefits = []): int
|
||||||
|
{
|
||||||
|
global $wpdb;
|
||||||
|
$wpdb->insert($wpdb->prefix . 'bsc_tier', [
|
||||||
|
'slug' => $slug,
|
||||||
|
'name' => $name,
|
||||||
|
'sort_order' => $sort_order,
|
||||||
|
'benefits' => wp_json_encode($benefits),
|
||||||
|
], ['%s', '%s', '%d', '%s']);
|
||||||
|
return (int) $wpdb->insert_id;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function update_benefits(string $slug, array $benefits): bool
|
||||||
|
{
|
||||||
|
global $wpdb;
|
||||||
|
$updated = $wpdb->update(
|
||||||
|
$wpdb->prefix . 'bsc_tier',
|
||||||
|
['benefits' => wp_json_encode($benefits)],
|
||||||
|
['slug' => $slug],
|
||||||
|
['%s'],
|
||||||
|
['%s']
|
||||||
|
);
|
||||||
|
return $updated !== false;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Full-row update (name/sort_order/benefits at once) — what the admin settings screen needs; the CLI's finer-grained set_benefit()/remove_benefit() below stay slug-addressed for convenience. */
|
||||||
|
public static function update(int $tier_id, string $name, int $sort_order, array $benefits): bool
|
||||||
|
{
|
||||||
|
global $wpdb;
|
||||||
|
$updated = $wpdb->update(
|
||||||
|
$wpdb->prefix . 'bsc_tier',
|
||||||
|
['name' => $name, 'sort_order' => $sort_order, 'benefits' => wp_json_encode($benefits)],
|
||||||
|
['tier_id' => $tier_id],
|
||||||
|
['%s', '%d', '%s'],
|
||||||
|
['%d']
|
||||||
|
);
|
||||||
|
return $updated !== false;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function set_benefit(string $slug, string $type, array $config): bool
|
||||||
|
{
|
||||||
|
$tier = self::find_by_slug($slug);
|
||||||
|
if (!$tier) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
$benefits = $tier['benefits'];
|
||||||
|
$benefits[$type] = $config;
|
||||||
|
return self::update_benefits($slug, $benefits);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function remove_benefit(string $slug, string $type): bool
|
||||||
|
{
|
||||||
|
$tier = self::find_by_slug($slug);
|
||||||
|
if (!$tier) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
$benefits = $tier['benefits'];
|
||||||
|
unset($benefits[$type]);
|
||||||
|
return self::update_benefits($slug, $benefits);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Refuses to delete the `guest` tier (the universal fallback every
|
||||||
|
* lookup resolves to) or a tier any bsc_member row still points at —
|
||||||
|
* returns a human-readable reason string on refusal, true on success.
|
||||||
|
*/
|
||||||
|
public static function delete(string $slug): bool|string
|
||||||
|
{
|
||||||
|
if ($slug === self::GUEST_SLUG) {
|
||||||
|
return 'cannot delete the guest tier — it is the universal fallback';
|
||||||
|
}
|
||||||
|
$tier = self::find_by_slug($slug);
|
||||||
|
if (!$tier) {
|
||||||
|
return 'no such tier';
|
||||||
|
}
|
||||||
|
global $wpdb;
|
||||||
|
$member_count = (int) $wpdb->get_var($wpdb->prepare(
|
||||||
|
"SELECT COUNT(*) FROM {$wpdb->prefix}bsc_member WHERE tier_id = %d",
|
||||||
|
$tier['tier_id']
|
||||||
|
));
|
||||||
|
if ($member_count > 0) {
|
||||||
|
return "tier still has {$member_count} member(s) assigned — reassign them first";
|
||||||
|
}
|
||||||
|
$wpdb->delete($wpdb->prefix . 'bsc_tier', ['tier_id' => $tier['tier_id']], ['%d']);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function assign_member(int $user_id, string $slug): bool
|
||||||
|
{
|
||||||
|
$tier = self::find_by_slug($slug);
|
||||||
|
if (!$tier) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
global $wpdb;
|
||||||
|
$exists = (int) $wpdb->get_var($wpdb->prepare(
|
||||||
|
"SELECT COUNT(*) FROM {$wpdb->prefix}bsc_member WHERE user_id = %d",
|
||||||
|
$user_id
|
||||||
|
));
|
||||||
|
if ($exists) {
|
||||||
|
$wpdb->update($wpdb->prefix . 'bsc_member', ['tier_id' => $tier['tier_id']], ['user_id' => $user_id], ['%d'], ['%d']);
|
||||||
|
} else {
|
||||||
|
$wpdb->insert($wpdb->prefix . 'bsc_member', [
|
||||||
|
'user_id' => $user_id,
|
||||||
|
'tier_id' => $tier['tier_id'],
|
||||||
|
], ['%d', '%d']);
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The one lookup Shipping\BSC_Free_Shipping and MemberDiscount both
|
||||||
|
* need: resolve the user's tier (falling back to `guest` if they
|
||||||
|
* have no bsc_member row, or their tier_id doesn't resolve), then
|
||||||
|
* pull one benefit type's config out of it. Null if that tier has
|
||||||
|
* no such benefit configured.
|
||||||
|
*/
|
||||||
|
public static function benefit_config_for_user(int $user_id, string $type): ?array
|
||||||
|
{
|
||||||
|
$tier = self::tier_for_user($user_id);
|
||||||
|
if (!$tier) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return $tier['benefits'][$type] ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function tier_for_user(int $user_id): ?array
|
||||||
|
{
|
||||||
|
global $wpdb;
|
||||||
|
$tier_id = $user_id
|
||||||
|
? (int) $wpdb->get_var($wpdb->prepare(
|
||||||
|
"SELECT tier_id FROM {$wpdb->prefix}bsc_member WHERE user_id = %d",
|
||||||
|
$user_id
|
||||||
|
))
|
||||||
|
: 0;
|
||||||
|
if ($tier_id) {
|
||||||
|
$tier = self::find($tier_id);
|
||||||
|
if ($tier) {
|
||||||
|
return $tier;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return self::find_by_slug(self::GUEST_SLUG);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Seeds guest/free_member with today's real defaults — only if
|
||||||
|
* missing by slug, so a human's later edits via the CLI/settings
|
||||||
|
* screen are never clobbered on a later plugins_loaded (same
|
||||||
|
* never-overwrite convention as Payments\HelcimConfig).
|
||||||
|
*/
|
||||||
|
public static function seed_defaults(): void
|
||||||
|
{
|
||||||
|
if (!self::find_by_slug(self::GUEST_SLUG)) {
|
||||||
|
self::create(self::GUEST_SLUG, 'Guest', 0, [
|
||||||
|
'free_shipping_threshold' => ['threshold' => 25.00],
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
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],
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static function hydrate(array $row): array
|
||||||
|
{
|
||||||
|
$row['tier_id'] = (int) $row['tier_id'];
|
||||||
|
$row['sort_order'] = (int) $row['sort_order'];
|
||||||
|
$row['benefits'] = $row['benefits'] ? (json_decode($row['benefits'], true) ?: []) : [];
|
||||||
|
return $row;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -2,6 +2,8 @@
|
|||||||
|
|
||||||
namespace Bookstore\Core;
|
namespace Bookstore\Core;
|
||||||
|
|
||||||
|
use Bookstore\Core\Membership\Tier;
|
||||||
|
|
||||||
defined('ABSPATH') || exit;
|
defined('ABSPATH') || exit;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -83,12 +85,35 @@ class Schema
|
|||||||
KEY isbn13_status_price (isbn13, status, base_price)
|
KEY isbn13_status_price (isbn13, status, base_price)
|
||||||
) {$charset_collate};";
|
) {$charset_collate};";
|
||||||
|
|
||||||
|
// v3: is_free_member's single boolean became a real tier system —
|
||||||
|
// see Membership\Tier. tier_id NULL means "no tier assigned",
|
||||||
|
// which every lookup treats the same as the guest tier.
|
||||||
$sql[] = "CREATE TABLE {$prefix}member (
|
$sql[] = "CREATE TABLE {$prefix}member (
|
||||||
user_id BIGINT UNSIGNED NOT NULL,
|
user_id BIGINT UNSIGNED NOT NULL,
|
||||||
is_free_member TINYINT(1) NOT NULL DEFAULT 0,
|
tier_id BIGINT UNSIGNED NULL,
|
||||||
marketing_consent TINYINT(1) NOT NULL DEFAULT 0,
|
marketing_consent TINYINT(1) NOT NULL DEFAULT 0,
|
||||||
consent_captured_at DATETIME NULL,
|
consent_captured_at DATETIME NULL,
|
||||||
PRIMARY KEY (user_id)
|
PRIMARY KEY (user_id),
|
||||||
|
KEY tier_id (tier_id)
|
||||||
|
) {$charset_collate};";
|
||||||
|
|
||||||
|
// v3: admin-configurable membership tiers (Membership\Tier).
|
||||||
|
// `benefits` is JSON-encoded, not a normalized child table —
|
||||||
|
// same LONGTEXT-over-JSON-column-type precedent as work.subjects
|
||||||
|
// above, and the tier count will always be small enough that
|
||||||
|
// "load all tiers, decode JSON in PHP" is the only query pattern
|
||||||
|
// that matters. This is what makes adding a tier, or changing an
|
||||||
|
// existing tier's benefit values, a pure data change with no
|
||||||
|
// migration.
|
||||||
|
$sql[] = "CREATE TABLE {$prefix}tier (
|
||||||
|
tier_id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
|
||||||
|
slug VARCHAR(50) NOT NULL,
|
||||||
|
name VARCHAR(100) NOT NULL,
|
||||||
|
sort_order INT NOT NULL DEFAULT 0,
|
||||||
|
benefits LONGTEXT NULL,
|
||||||
|
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
PRIMARY KEY (tier_id),
|
||||||
|
UNIQUE KEY slug (slug)
|
||||||
) {$charset_collate};";
|
) {$charset_collate};";
|
||||||
|
|
||||||
// Week 3 (design doc Appendix B / §08 audit data): one row per
|
// Week 3 (design doc Appendix B / §08 audit data): one row per
|
||||||
@@ -123,12 +148,31 @@ class Schema
|
|||||||
}
|
}
|
||||||
|
|
||||||
update_option('bsc_schema_version', BSC_SCHEMA_VERSION);
|
update_option('bsc_schema_version', BSC_SCHEMA_VERSION);
|
||||||
|
|
||||||
|
Tier::seed_defaults();
|
||||||
}
|
}
|
||||||
|
|
||||||
public static function maybe_upgrade(): void
|
public static function maybe_upgrade(): void
|
||||||
{
|
{
|
||||||
if (get_option('bsc_schema_version') !== BSC_SCHEMA_VERSION) {
|
$current = get_option('bsc_schema_version');
|
||||||
self::install();
|
if ($current === BSC_SCHEMA_VERSION) {
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// v2 -> v3: bsc_member's is_free_member flag became tier_id (see
|
||||||
|
// Membership\Tier). Pre-launch, every environment only holds
|
||||||
|
// synthetic/test rows, so there's nothing worth backfilling —
|
||||||
|
// drop and let install() recreate it fresh with the new shape.
|
||||||
|
// dbDelta() never drops/renames columns on its own, so this is
|
||||||
|
// the only way an already-deployed site picks up the change.
|
||||||
|
// Once real customer data exists, schema changes go back to
|
||||||
|
// real backfill-preserving migrations — this is a pre-launch-
|
||||||
|
// only shortcut, not a standing pattern.
|
||||||
|
if ($current !== false && version_compare((string) $current, '3', '<')) {
|
||||||
|
global $wpdb;
|
||||||
|
$wpdb->query("DROP TABLE IF EXISTS {$wpdb->prefix}bsc_member");
|
||||||
|
}
|
||||||
|
|
||||||
|
self::install();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -2,15 +2,19 @@
|
|||||||
|
|
||||||
namespace Bookstore\Core\Shipping;
|
namespace Bookstore\Core\Shipping;
|
||||||
|
|
||||||
|
use Bookstore\Core\Membership\Tier;
|
||||||
|
|
||||||
defined('ABSPATH') || exit;
|
defined('ABSPATH') || exit;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* A real WC_Shipping_Method, not WooCommerce's built-in Free Shipping —
|
* A real WC_Shipping_Method, not WooCommerce's built-in Free Shipping —
|
||||||
* that only supports one static threshold per instance, and design doc §07
|
* that only supports one static threshold per instance, and design doc §07
|
||||||
* needs two different ones depending on membership. Thresholds are stored
|
* needs a different one per membership tier. The threshold comes from the
|
||||||
* as options (bsc_shipping_threshold_guest/_member), not hardcoded, so the
|
* current customer's `free_shipping_threshold` tier benefit
|
||||||
* launch gate's automated test can assert against the same source this
|
* (Membership\Tier) rather than a hardcoded option — a human can change it
|
||||||
* reads from.
|
* per tier via `wp bookstore tier` or the settings screen without a
|
||||||
|
* deploy, and the launch gate's automated test can assert against the
|
||||||
|
* same source this reads from.
|
||||||
*/
|
*/
|
||||||
class BSC_Free_Shipping extends \WC_Shipping_Method
|
class BSC_Free_Shipping extends \WC_Shipping_Method
|
||||||
{
|
{
|
||||||
@@ -43,19 +47,8 @@ class BSC_Free_Shipping extends \WC_Shipping_Method
|
|||||||
public static function threshold_for_current_customer(): float
|
public static function threshold_for_current_customer(): float
|
||||||
{
|
{
|
||||||
$user_id = get_current_user_id();
|
$user_id = get_current_user_id();
|
||||||
$is_member = $user_id && self::is_free_member($user_id);
|
$config = Tier::benefit_config_for_user($user_id, 'free_shipping_threshold');
|
||||||
return $is_member
|
return isset($config['threshold']) ? (float) $config['threshold'] : 25.00;
|
||||||
? (float) get_option('bsc_shipping_threshold_member', 15.00)
|
|
||||||
: (float) get_option('bsc_shipping_threshold_guest', 25.00);
|
|
||||||
}
|
|
||||||
|
|
||||||
private static function is_free_member(int $user_id): bool
|
|
||||||
{
|
|
||||||
global $wpdb;
|
|
||||||
return (bool) $wpdb->get_var($wpdb->prepare(
|
|
||||||
"SELECT is_free_member FROM {$wpdb->prefix}bsc_member WHERE user_id = %d",
|
|
||||||
$user_id
|
|
||||||
));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public static function ensure_enabled_on_default_zone(): void
|
public static function ensure_enabled_on_default_zone(): void
|
||||||
|
|||||||
@@ -3,10 +3,20 @@
|
|||||||
namespace Bookstore\Core\Tests;
|
namespace Bookstore\Core\Tests;
|
||||||
|
|
||||||
use Brain\Monkey;
|
use Brain\Monkey;
|
||||||
|
use Mockery\Adapter\Phpunit\MockeryPHPUnitIntegration;
|
||||||
use PHPUnit\Framework\TestCase as PHPUnitTestCase;
|
use PHPUnit\Framework\TestCase as PHPUnitTestCase;
|
||||||
|
|
||||||
abstract class TestCase extends PHPUnitTestCase
|
abstract class TestCase extends PHPUnitTestCase
|
||||||
{
|
{
|
||||||
|
// Without this, Mockery's shouldReceive()/shouldNotReceive()
|
||||||
|
// expectations are only ever verified at Mockery::close() — PHPUnit
|
||||||
|
// never sees them as assertions, so a test that mocks a call and
|
||||||
|
// never asserts anything else passes silently even if the mocked
|
||||||
|
// call was never (or wrongly) made, just flagged "risky." This
|
||||||
|
// trait's assertPostConditions() hook feeds Mockery's own verified-
|
||||||
|
// expectation count into PHPUnit's assertion count instead.
|
||||||
|
use MockeryPHPUnitIntegration;
|
||||||
|
|
||||||
protected function setUp(): void
|
protected function setUp(): void
|
||||||
{
|
{
|
||||||
parent::setUp();
|
parent::setUp();
|
||||||
|
|||||||
@@ -0,0 +1,114 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace Bookstore\Core\Tests\Unit\Membership;
|
||||||
|
|
||||||
|
use Bookstore\Core\Membership\MemberDiscount;
|
||||||
|
use Bookstore\Core\Tests\TestCase;
|
||||||
|
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.
|
||||||
|
*/
|
||||||
|
class MemberDiscountTest extends TestCase
|
||||||
|
{
|
||||||
|
protected function tearDown(): void
|
||||||
|
{
|
||||||
|
global $wpdb;
|
||||||
|
$wpdb = null;
|
||||||
|
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
|
||||||
|
{
|
||||||
|
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],
|
||||||
|
]);
|
||||||
|
|
||||||
|
$cart = Mockery::mock(\WC_Cart::class);
|
||||||
|
$cart->shouldReceive('get_subtotal')->andReturn('80.00');
|
||||||
|
$cart->shouldNotReceive('add_fee');
|
||||||
|
|
||||||
|
MemberDiscount::apply($cart);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_member_at_min_spend_gets_the_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],
|
||||||
|
]);
|
||||||
|
|
||||||
|
$cart = Mockery::mock(\WC_Cart::class);
|
||||||
|
$cart->shouldReceive('get_subtotal')->andReturn('100.00');
|
||||||
|
$cart->shouldReceive('add_fee')->once()->with('Member discount', -10.00, false);
|
||||||
|
|
||||||
|
MemberDiscount::apply($cart);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_member_above_min_spend_gets_the_discount(): 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],
|
||||||
|
]);
|
||||||
|
|
||||||
|
$cart = Mockery::mock(\WC_Cart::class);
|
||||||
|
$cart->shouldReceive('get_subtotal')->andReturn('142.50');
|
||||||
|
$cart->shouldReceive('add_fee')->once()->with('Member discount', -10.00, false);
|
||||||
|
|
||||||
|
MemberDiscount::apply($cart);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function stub_wpdb_anonymous(array $benefits): void
|
||||||
|
{
|
||||||
|
global $wpdb;
|
||||||
|
$wpdb = Mockery::mock('wpdb');
|
||||||
|
$wpdb->prefix = 'wp_';
|
||||||
|
$wpdb->shouldReceive('prepare')->once()->andReturn('SQL');
|
||||||
|
$wpdb->shouldReceive('get_var')->never();
|
||||||
|
$wpdb->shouldReceive('get_row')->once()->with('SQL', ARRAY_A)->andReturn([
|
||||||
|
'tier_id' => 1,
|
||||||
|
'slug' => 'guest',
|
||||||
|
'name' => 'Guest',
|
||||||
|
'sort_order' => 0,
|
||||||
|
'benefits' => json_encode($benefits),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function stub_wpdb_with_member_lookup(int $tier_id, array $benefits): void
|
||||||
|
{
|
||||||
|
global $wpdb;
|
||||||
|
$wpdb = Mockery::mock('wpdb');
|
||||||
|
$wpdb->prefix = 'wp_';
|
||||||
|
$wpdb->shouldReceive('prepare')->twice()->andReturn('SQL');
|
||||||
|
$wpdb->shouldReceive('get_var')->once()->andReturn($tier_id);
|
||||||
|
$wpdb->shouldReceive('get_row')->once()->with('SQL', ARRAY_A)->andReturn([
|
||||||
|
'tier_id' => $tier_id,
|
||||||
|
'slug' => 'free_member',
|
||||||
|
'name' => 'Free Member',
|
||||||
|
'sort_order' => 10,
|
||||||
|
'benefits' => json_encode($benefits),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -9,13 +9,12 @@ use Mockery;
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Launch gate: "$25/$15 shipping thresholds have automated tests" (design
|
* Launch gate: "$25/$15 shipping thresholds have automated tests" (design
|
||||||
* doc §5/§8). threshold_for_current_customer() reads membership via a raw
|
* doc §5/§8), now sourced from the current customer's tier
|
||||||
* $wpdb query and thresholds via get_option() (with the $25/$15 launch
|
* (Membership\Tier) rather than a hardcoded get_option() default. The
|
||||||
* numbers as the fallback defaults) — both are stubbed here rather than
|
* real Tier class runs unmocked here — only $wpdb (the layer underneath
|
||||||
* hitting WordPress/a real database. The class's own docblock says
|
* Tier's own queries) and get_current_user_id() are stubbed, so these
|
||||||
* thresholds are option-backed "so the launch gate's automated test can
|
* tests exercise the actual guest-fallback/tier-resolution logic, not
|
||||||
* assert against the same source this reads from" — the last test below
|
* just BSC_Free_Shipping's arithmetic.
|
||||||
* proves exactly that.
|
|
||||||
*/
|
*/
|
||||||
class BSCFreeShippingTest extends TestCase
|
class BSCFreeShippingTest extends TestCase
|
||||||
{
|
{
|
||||||
@@ -29,16 +28,19 @@ class BSCFreeShippingTest extends TestCase
|
|||||||
public function test_guest_gets_default_25_dollar_threshold(): void
|
public function test_guest_gets_default_25_dollar_threshold(): void
|
||||||
{
|
{
|
||||||
Functions\when('get_current_user_id')->justReturn(0);
|
Functions\when('get_current_user_id')->justReturn(0);
|
||||||
Functions\when('get_option')->alias(fn($name, $default) => $default);
|
// user_id 0 short-circuits Tier::tier_for_user()'s ternary --
|
||||||
|
// $wpdb->get_var() is never called, only find_by_slug('guest').
|
||||||
|
$this->stub_wpdb_anonymous($this->tier_row('guest', ['free_shipping_threshold' => ['threshold' => 25.00]]));
|
||||||
|
|
||||||
$this->assertSame(25.00, BSC_Free_Shipping::threshold_for_current_customer());
|
$this->assertSame(25.00, BSC_Free_Shipping::threshold_for_current_customer());
|
||||||
}
|
}
|
||||||
|
|
||||||
public function test_logged_in_non_member_gets_default_25_dollar_threshold(): void
|
public function test_logged_in_non_member_gets_guest_threshold(): void
|
||||||
{
|
{
|
||||||
Functions\when('get_current_user_id')->justReturn(42);
|
Functions\when('get_current_user_id')->justReturn(42);
|
||||||
Functions\when('get_option')->alias(fn($name, $default) => $default);
|
// No bsc_member row (or a NULL tier_id) -> get_var returns NULL,
|
||||||
$this->mock_wpdb_is_free_member(false);
|
// same guest fallback as an anonymous visitor.
|
||||||
|
$this->stub_wpdb_with_member_lookup(null, $this->tier_row('guest', ['free_shipping_threshold' => ['threshold' => 25.00]]));
|
||||||
|
|
||||||
$this->assertSame(25.00, BSC_Free_Shipping::threshold_for_current_customer());
|
$this->assertSame(25.00, BSC_Free_Shipping::threshold_for_current_customer());
|
||||||
}
|
}
|
||||||
@@ -46,29 +48,60 @@ class BSCFreeShippingTest extends TestCase
|
|||||||
public function test_free_member_gets_15_dollar_threshold(): void
|
public function test_free_member_gets_15_dollar_threshold(): void
|
||||||
{
|
{
|
||||||
Functions\when('get_current_user_id')->justReturn(7);
|
Functions\when('get_current_user_id')->justReturn(7);
|
||||||
Functions\when('get_option')->alias(fn($name, $default) => $default);
|
$this->stub_wpdb_with_member_lookup(5, $this->tier_row('free_member', [
|
||||||
$this->mock_wpdb_is_free_member(true);
|
'free_shipping_threshold' => ['threshold' => 15.00],
|
||||||
|
'spend_discount' => ['min_spend' => 100.00, 'discount_amount' => 10.00],
|
||||||
|
]));
|
||||||
|
|
||||||
$this->assertSame(15.00, BSC_Free_Shipping::threshold_for_current_customer());
|
$this->assertSame(15.00, BSC_Free_Shipping::threshold_for_current_customer());
|
||||||
}
|
}
|
||||||
|
|
||||||
public function test_custom_option_override_is_respected_not_the_hardcoded_default(): void
|
public function test_tier_benefit_value_is_respected_not_a_hardcoded_default(): void
|
||||||
{
|
{
|
||||||
Functions\when('get_current_user_id')->justReturn(0);
|
Functions\when('get_current_user_id')->justReturn(0);
|
||||||
Functions\expect('get_option')
|
$this->stub_wpdb_anonymous($this->tier_row('guest', ['free_shipping_threshold' => ['threshold' => 29.99]]));
|
||||||
->once()
|
|
||||||
->with('bsc_shipping_threshold_guest', 25.00)
|
|
||||||
->andReturn(29.99);
|
|
||||||
|
|
||||||
$this->assertSame(29.99, BSC_Free_Shipping::threshold_for_current_customer());
|
$this->assertSame(29.99, BSC_Free_Shipping::threshold_for_current_customer());
|
||||||
}
|
}
|
||||||
|
|
||||||
private function mock_wpdb_is_free_member(bool $is_member): void
|
public function test_tier_with_no_shipping_benefit_falls_back_to_25(): void
|
||||||
|
{
|
||||||
|
Functions\when('get_current_user_id')->justReturn(0);
|
||||||
|
$this->stub_wpdb_anonymous($this->tier_row('guest', []));
|
||||||
|
|
||||||
|
$this->assertSame(25.00, BSC_Free_Shipping::threshold_for_current_customer());
|
||||||
|
}
|
||||||
|
|
||||||
|
private function tier_row(string $slug, array $benefits): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'tier_id' => 1,
|
||||||
|
'slug' => $slug,
|
||||||
|
'name' => ucfirst($slug),
|
||||||
|
'sort_order' => 0,
|
||||||
|
'benefits' => json_encode($benefits),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** user_id 0: Tier::tier_for_user() never calls get_var, only find_by_slug('guest') (one prepare+get_row). */
|
||||||
|
private function stub_wpdb_anonymous(array $resolved_tier_row): void
|
||||||
{
|
{
|
||||||
global $wpdb;
|
global $wpdb;
|
||||||
$wpdb = Mockery::mock('wpdb');
|
$wpdb = Mockery::mock('wpdb');
|
||||||
$wpdb->prefix = 'wp_';
|
$wpdb->prefix = 'wp_';
|
||||||
$wpdb->shouldReceive('prepare')->once()->andReturn('SELECT is_free_member ...');
|
$wpdb->shouldReceive('prepare')->once()->andReturn('SQL');
|
||||||
$wpdb->shouldReceive('get_var')->once()->andReturn($is_member ? '1' : '0');
|
$wpdb->shouldReceive('get_var')->never();
|
||||||
|
$wpdb->shouldReceive('get_row')->once()->with('SQL', ARRAY_A)->andReturn($resolved_tier_row);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A real user_id: one get_var (member lookup) then one get_row (either find() or the guest fallback), both via prepare(). */
|
||||||
|
private function stub_wpdb_with_member_lookup(?int $member_tier_id, array $resolved_tier_row): void
|
||||||
|
{
|
||||||
|
global $wpdb;
|
||||||
|
$wpdb = Mockery::mock('wpdb');
|
||||||
|
$wpdb->prefix = 'wp_';
|
||||||
|
$wpdb->shouldReceive('prepare')->twice()->andReturn('SQL');
|
||||||
|
$wpdb->shouldReceive('get_var')->once()->andReturn($member_tier_id);
|
||||||
|
$wpdb->shouldReceive('get_row')->once()->with('SQL', ARRAY_A)->andReturn($resolved_tier_row);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -20,3 +20,24 @@ if (!class_exists(\WC_Shipping_Method::class)) {
|
|||||||
{
|
{
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (!class_exists(\WC_Cart::class)) {
|
||||||
|
class WC_Cart
|
||||||
|
{
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Membership\Tier calls $wpdb->get_row(..., ARRAY_A) — these are real
|
||||||
|
// WordPress core constants (wp-includes/wp-db.php) that a full WP
|
||||||
|
// bootstrap would define; this one deliberately doesn't load WordPress,
|
||||||
|
// so they need stubbing here with their real values (get_row()/
|
||||||
|
// get_results() branch on the literal string, not just "is it set").
|
||||||
|
if (!defined('OBJECT')) {
|
||||||
|
define('OBJECT', 'OBJECT');
|
||||||
|
}
|
||||||
|
if (!defined('ARRAY_A')) {
|
||||||
|
define('ARRAY_A', 'ARRAY_A');
|
||||||
|
}
|
||||||
|
if (!defined('ARRAY_N')) {
|
||||||
|
define('ARRAY_N', 'ARRAY_N');
|
||||||
|
}
|
||||||
Reference in new issue
Block a user