From 13e736257953d9af5b2aa5f48f59cd50c106f5c9 Mon Sep 17 00:00:00 2001 From: Twooey Date: Fri, 28 Aug 2026 11:56:06 -0400 Subject: [PATCH] Replace the single is_free_member flag with a real member-tier system MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 Claude-Session: https://claude.ai/code/session_01MqhgnCRjjVLFnZ3Jpn93Vg --- deploy/deploy.sh | 6 + .../plugins/bookstore-core/bookstore-core.php | 11 +- .../includes/Admin/TierSettings.php | 158 ++++++++++++ .../bookstore-core/includes/Cli/Commands.php | 177 ++++++++++++++ .../includes/Membership/MemberDiscount.php | 45 ++++ .../includes/Membership/Registration.php | 52 ++++ .../includes/Membership/Tier.php | 225 ++++++++++++++++++ .../bookstore-core/includes/Schema.php | 52 +++- .../includes/Shipping/BSC_Free_Shipping.php | 27 +-- .../plugins/bookstore-core/tests/TestCase.php | 10 + .../Unit/Membership/MemberDiscountTest.php | 114 +++++++++ .../Unit/Shipping/BSCFreeShippingTest.php | 75 ++++-- .../bookstore-core/tests/bootstrap.php | 21 ++ 13 files changed, 930 insertions(+), 43 deletions(-) create mode 100644 wp-content/plugins/bookstore-core/includes/Admin/TierSettings.php create mode 100644 wp-content/plugins/bookstore-core/includes/Membership/MemberDiscount.php create mode 100644 wp-content/plugins/bookstore-core/includes/Membership/Registration.php create mode 100644 wp-content/plugins/bookstore-core/includes/Membership/Tier.php create mode 100644 wp-content/plugins/bookstore-core/tests/Unit/Membership/MemberDiscountTest.php diff --git a/deploy/deploy.sh b/deploy/deploy.sh index a7f2543..53f0a6d 100755 --- a/deploy/deploy.sh +++ b/deploy/deploy.sh @@ -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_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 # thumbnail to a square — confirmed directly: a 335x500 portrait book cover # was landing at a hard-cropped 300x300 shop-grid thumbnail, cutting off diff --git a/wp-content/plugins/bookstore-core/bookstore-core.php b/wp-content/plugins/bookstore-core/bookstore-core.php index 373ad83..1286622 100644 --- a/wp-content/plugins/bookstore-core/bookstore-core.php +++ b/wp-content/plugins/bookstore-core/bookstore-core.php @@ -13,7 +13,7 @@ defined('ABSPATH') || exit; define('BSC_PLUGIN_FILE', __FILE__); define('BSC_PLUGIN_DIR', plugin_dir_path(__FILE__)); define('BSC_VERSION', '0.2.0'); -define('BSC_SCHEMA_VERSION', '2'); +define('BSC_SCHEMA_VERSION', '3'); spl_autoload_register(function (string $class) { $prefix = 'Bookstore\\Core\\'; @@ -59,6 +59,15 @@ add_filter('woocommerce_shipping_methods', function (array $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 () { \Bookstore\Core\Taxonomy\TagTaxonomies::register(); }); diff --git a/wp-content/plugins/bookstore-core/includes/Admin/TierSettings.php b/wp-content/plugins/bookstore-core/includes/Admin/TierSettings.php new file mode 100644 index 0000000..b4e4b46 --- /dev/null +++ b/wp-content/plugins/bookstore-core/includes/Admin/TierSettings.php @@ -0,0 +1,158 @@ + 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
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 '

' . esc_html($error) . '

'; + } + + $tiers = Tier::all(); + ?> +

Membership Tiers

+

Each tier's benefits apply automatically to whoever is assigned to it — see wp bookstore tier assign <user> <slug> for assigning members from the command line.

+ + + + + + + + + + + + + + + + + + + + + + + + + + + +
Name / slugSort orderFree shipping thresholdMember discount
+ +
+
+ + +
+ $ +
+
+ save $ + on orders $+ +
+ + + +

New tier — leave benefit fields on existing rows for now, add them after saving.
+ $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); + } + } +} diff --git a/wp-content/plugins/bookstore-core/includes/Cli/Commands.php b/wp-content/plugins/bookstore-core/includes/Cli/Commands.php index 9aeb4a7..c1a0d38 100644 --- a/wp-content/plugins/bookstore-core/includes/Cli/Commands.php +++ b/wp-content/plugins/bookstore-core/includes/Cli/Commands.php @@ -10,6 +10,7 @@ use Bookstore\Core\Import\BooksRunOfferSync; use Bookstore\Core\Import\GutenbergImporter; use Bookstore\Core\Import\SyntheticOfferGenerator; use Bookstore\Core\Integration\HardcoverAdapter; +use Bookstore\Core\Membership\Tier; use Bookstore\Core\Product\CoverSync; use Bookstore\Core\Product\ProductSync; use Bookstore\Core\Taxonomy\TagTaxonomies; @@ -389,4 +390,180 @@ class Commands $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 + * + * + * : Unique slug (e.g. gold). + * + * + * : Display name. + * + * [--sort-order=] + * : 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": }) and + * spend_discount (Membership\MemberDiscount, expects {"min_spend": + * , "discount_amount": }). Any other type is stored + * but has no runtime effect until something reads it — see the + * class docblocks for why. + * + * ## OPTIONS + * + * + * : Tier slug. + * + * + * : Benefit type key. + * + * + * : 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 + * + * + * : Tier slug. + * + * + * : 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 ID, login, or email. + * + * + * : 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 + * + * + * : 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}'."); + } } diff --git a/wp-content/plugins/bookstore-core/includes/Membership/MemberDiscount.php b/wp-content/plugins/bookstore-core/includes/Membership/MemberDiscount.php new file mode 100644 index 0000000..5141a93 --- /dev/null +++ b/wp-content/plugins/bookstore-core/includes/Membership/MemberDiscount.php @@ -0,0 +1,45 @@ +get_subtotal(); + if ($subtotal < (float) $config['min_spend']) { + return; + } + + $cart->add_fee('Member discount', -1 * (float) $config['discount_amount'], false); + } +} diff --git a/wp-content/plugins/bookstore-core/includes/Membership/Registration.php b/wp-content/plugins/bookstore-core/includes/Membership/Registration.php new file mode 100644 index 0000000..a8c27a5 --- /dev/null +++ b/wp-content/plugins/bookstore-core/includes/Membership/Registration.php @@ -0,0 +1,52 @@ + +

+ +

+ 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']); + } +} diff --git a/wp-content/plugins/bookstore-core/includes/Membership/Tier.php b/wp-content/plugins/bookstore-core/includes/Membership/Tier.php new file mode 100644 index 0000000..08e4e4f --- /dev/null +++ b/wp-content/plugins/bookstore-core/includes/Membership/Tier.php @@ -0,0 +1,225 @@ + */ + 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; + } +} diff --git a/wp-content/plugins/bookstore-core/includes/Schema.php b/wp-content/plugins/bookstore-core/includes/Schema.php index db68ab0..5c73096 100644 --- a/wp-content/plugins/bookstore-core/includes/Schema.php +++ b/wp-content/plugins/bookstore-core/includes/Schema.php @@ -2,6 +2,8 @@ namespace Bookstore\Core; +use Bookstore\Core\Membership\Tier; + defined('ABSPATH') || exit; /** @@ -83,12 +85,35 @@ class Schema KEY isbn13_status_price (isbn13, status, base_price) ) {$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 ( 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, 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};"; // 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); + + Tier::seed_defaults(); } public static function maybe_upgrade(): void { - if (get_option('bsc_schema_version') !== BSC_SCHEMA_VERSION) { - self::install(); + $current = get_option('bsc_schema_version'); + 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(); } } diff --git a/wp-content/plugins/bookstore-core/includes/Shipping/BSC_Free_Shipping.php b/wp-content/plugins/bookstore-core/includes/Shipping/BSC_Free_Shipping.php index 3f86a7a..292f966 100644 --- a/wp-content/plugins/bookstore-core/includes/Shipping/BSC_Free_Shipping.php +++ b/wp-content/plugins/bookstore-core/includes/Shipping/BSC_Free_Shipping.php @@ -2,15 +2,19 @@ namespace Bookstore\Core\Shipping; +use Bookstore\Core\Membership\Tier; + defined('ABSPATH') || exit; /** * A real WC_Shipping_Method, not WooCommerce's built-in Free Shipping — * that only supports one static threshold per instance, and design doc §07 - * needs two different ones depending on membership. Thresholds are stored - * as options (bsc_shipping_threshold_guest/_member), not hardcoded, so the - * launch gate's automated test can assert against the same source this - * reads from. + * needs a different one per membership tier. The threshold comes from the + * current customer's `free_shipping_threshold` tier benefit + * (Membership\Tier) rather than a hardcoded option — a human can change it + * 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 { @@ -43,19 +47,8 @@ class BSC_Free_Shipping extends \WC_Shipping_Method public static function threshold_for_current_customer(): float { $user_id = get_current_user_id(); - $is_member = $user_id && self::is_free_member($user_id); - return $is_member - ? (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 - )); + $config = Tier::benefit_config_for_user($user_id, 'free_shipping_threshold'); + return isset($config['threshold']) ? (float) $config['threshold'] : 25.00; } public static function ensure_enabled_on_default_zone(): void diff --git a/wp-content/plugins/bookstore-core/tests/TestCase.php b/wp-content/plugins/bookstore-core/tests/TestCase.php index 13f0c5b..ee27bcf 100644 --- a/wp-content/plugins/bookstore-core/tests/TestCase.php +++ b/wp-content/plugins/bookstore-core/tests/TestCase.php @@ -3,10 +3,20 @@ namespace Bookstore\Core\Tests; use Brain\Monkey; +use Mockery\Adapter\Phpunit\MockeryPHPUnitIntegration; use PHPUnit\Framework\TestCase as 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 { parent::setUp(); diff --git a/wp-content/plugins/bookstore-core/tests/Unit/Membership/MemberDiscountTest.php b/wp-content/plugins/bookstore-core/tests/Unit/Membership/MemberDiscountTest.php new file mode 100644 index 0000000..0e221af --- /dev/null +++ b/wp-content/plugins/bookstore-core/tests/Unit/Membership/MemberDiscountTest.php @@ -0,0 +1,114 @@ +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), + ]); + } +} diff --git a/wp-content/plugins/bookstore-core/tests/Unit/Shipping/BSCFreeShippingTest.php b/wp-content/plugins/bookstore-core/tests/Unit/Shipping/BSCFreeShippingTest.php index cc722d2..df7eec9 100644 --- a/wp-content/plugins/bookstore-core/tests/Unit/Shipping/BSCFreeShippingTest.php +++ b/wp-content/plugins/bookstore-core/tests/Unit/Shipping/BSCFreeShippingTest.php @@ -9,13 +9,12 @@ use Mockery; /** * Launch gate: "$25/$15 shipping thresholds have automated tests" (design - * doc §5/§8). threshold_for_current_customer() reads membership via a raw - * $wpdb query and thresholds via get_option() (with the $25/$15 launch - * numbers as the fallback defaults) — both are stubbed here rather than - * hitting WordPress/a real database. The class's own docblock says - * thresholds are option-backed "so the launch gate's automated test can - * assert against the same source this reads from" — the last test below - * proves exactly that. + * doc §5/§8), now sourced from the current customer's tier + * (Membership\Tier) rather than a hardcoded get_option() default. The + * real Tier class runs unmocked here — only $wpdb (the layer underneath + * Tier's own queries) and get_current_user_id() are stubbed, so these + * tests exercise the actual guest-fallback/tier-resolution logic, not + * just BSC_Free_Shipping's arithmetic. */ class BSCFreeShippingTest extends TestCase { @@ -29,16 +28,19 @@ class BSCFreeShippingTest extends TestCase public function test_guest_gets_default_25_dollar_threshold(): void { 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()); } - 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_option')->alias(fn($name, $default) => $default); - $this->mock_wpdb_is_free_member(false); + // No bsc_member row (or a NULL tier_id) -> get_var returns NULL, + // 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()); } @@ -46,29 +48,60 @@ class BSCFreeShippingTest extends TestCase public function test_free_member_gets_15_dollar_threshold(): void { Functions\when('get_current_user_id')->justReturn(7); - Functions\when('get_option')->alias(fn($name, $default) => $default); - $this->mock_wpdb_is_free_member(true); + $this->stub_wpdb_with_member_lookup(5, $this->tier_row('free_member', [ + '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()); } - 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\expect('get_option') - ->once() - ->with('bsc_shipping_threshold_guest', 25.00) - ->andReturn(29.99); + $this->stub_wpdb_anonymous($this->tier_row('guest', ['free_shipping_threshold' => ['threshold' => 29.99]])); $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; $wpdb = Mockery::mock('wpdb'); $wpdb->prefix = 'wp_'; - $wpdb->shouldReceive('prepare')->once()->andReturn('SELECT is_free_member ...'); - $wpdb->shouldReceive('get_var')->once()->andReturn($is_member ? '1' : '0'); + $wpdb->shouldReceive('prepare')->once()->andReturn('SQL'); + $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); } } diff --git a/wp-content/plugins/bookstore-core/tests/bootstrap.php b/wp-content/plugins/bookstore-core/tests/bootstrap.php index 71d7262..0a4e542 100644 --- a/wp-content/plugins/bookstore-core/tests/bootstrap.php +++ b/wp-content/plugins/bookstore-core/tests/bootstrap.php @@ -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'); +}