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
@@ -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\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
|
||||
*
|
||||
* <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;
|
||||
|
||||
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();
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
Reference in new issue
Block a user