Building secure B2B pricing grids with custom WordPress Options API endpoints and role overrides
Leveraging the WordPress Options API for Secure B2B Pricing Grids
Building dynamic pricing structures for business-to-business (B2B) clients within WordPress requires a robust and secure method for storing and retrieving sensitive data. The native WordPress Options API, when combined with custom endpoints and role-based access control, provides a powerful and flexible solution. This approach avoids cluttering the post or user meta tables and offers a centralized, manageable location for your pricing configurations.
Registering Custom Options for Pricing Data
The first step is to define and register the options that will hold your B2B pricing data. This is best done within a custom plugin. We’ll use `register_setting()` to ensure these options are properly handled by WordPress’s settings API, which includes sanitization and validation hooks.
For this example, let’s assume we need to store pricing tiers based on customer roles and product IDs. We’ll create an option named b2b_pricing_grid which will store an array. Each element in the array will represent a pricing rule.
Plugin Activation Hook and Option Registration
We’ll hook into the plugin activation to set a default value for our pricing grid option. This ensures that the option exists even before any pricing data is manually entered.
/**
* Plugin activation hook.
* Sets a default empty pricing grid if the option doesn't exist.
*/
function my_b2b_plugin_activate() {
if ( false === get_option( 'b2b_pricing_grid' ) ) {
add_option( 'b2b_pricing_grid', array() );
}
}
register_activation_hook( __FILE__, 'my_b2b_plugin_activate' );
/**
* Registers the B2B pricing grid setting.
* This function should be called during plugin initialization (e.g., in an 'admin_init' hook).
*/
function my_b2b_register_settings() {
register_setting(
'b2b_pricing_options_group', // Option group
'b2b_pricing_grid', // Option name
array(
'type' => 'array',
'description' => __( 'Stores B2B pricing rules.', 'my-b2b-plugin' ),
'sanitize_callback' => 'my_b2b_sanitize_pricing_grid',
'default' => array(),
)
);
}
add_action( 'admin_init', 'my_b2b_register_settings' );
/**
* Sanitizes the B2B pricing grid data.
*
* @param array $input The raw input data.
* @return array The sanitized data.
*/
function my_b2b_sanitize_pricing_grid( $input ) {
if ( ! is_array( $input ) ) {
return array();
}
$sanitized_input = array();
foreach ( $input as $key => $rule ) {
if ( ! is_array( $rule ) ) {
continue;
}
$sanitized_rule = array();
// Example: Sanitize role, product_id, and price.
// In a real-world scenario, you'd have more robust validation.
if ( isset( $rule['role'] ) && is_string( $rule['role'] ) ) {
$sanitized_rule['role'] = sanitize_text_field( $rule['role'] );
}
if ( isset( $rule['product_id'] ) && is_numeric( $rule['product_id'] ) ) {
$sanitized_rule['product_id'] = absint( $rule['product_id'] );
}
if ( isset( $rule['price'] ) && is_numeric( $rule['price'] ) ) {
$sanitized_rule['price'] = floatval( $rule['price'] );
}
// Add other fields as necessary and sanitize them.
// Only add the rule if it has essential data.
if ( ! empty( $sanitized_rule['role'] ) && isset( $sanitized_rule['product_id'] ) && isset( $sanitized_rule['price'] ) ) {
$sanitized_input[] = $sanitized_rule;
}
}
return $sanitized_input;
}
Creating Custom REST API Endpoints
To dynamically fetch pricing data without exposing the entire options table, we’ll create custom REST API endpoints. This allows frontend JavaScript or other services to request specific pricing information.
Endpoint for Retrieving Pricing Data
We’ll register a route that allows fetching pricing rules, potentially filtered by product ID or user role. For security, we’ll ensure only authenticated users can access this endpoint.
/**
* Registers the REST API endpoint for B2B pricing.
*/
function my_b2b_register_api_routes() {
$namespace = 'my-b2b/v1';
$route = '/pricing';
register_rest_route( $namespace, $route, array(
'methods' => WP_REST_Server::READABLE, // GET method
'callback' => 'my_b2b_get_pricing_data',
'permission_callback' => function() {
// Ensure the user is logged in.
// For more granular control, check specific capabilities or roles.
return is_user_logged_in();
},
'args' => array(
'product_id' => array(
'required' => false,
'type' => 'integer',
'sanitize_callback' => 'absint',
'description' => __( 'Filter pricing by product ID.', 'my-b2b-plugin' ),
),
'role' => array(
'required' => false,
'type' => 'string',
'sanitize_callback' => 'sanitize_text_field',
'description' => __( 'Filter pricing by user role.', 'my-b2b-plugin' ),
),
),
) );
}
add_action( 'rest_api_init', 'my_b2b_register_api_routes' );
/**
* Callback function for the pricing API endpoint.
*
* @param WP_REST_Request $request Full data about the request.
* @return WP_REST_Response|WP_Error Response object on success, or WP_Error object on failure.
*/
function my_b2b_get_pricing_data( WP_REST_Request $request ) {
$pricing_grid = get_option( 'b2b_pricing_grid', array() );
if ( ! is_array( $pricing_grid ) ) {
$pricing_grid = array(); // Ensure it's an array
}
$product_id = $request->get_param( 'product_id' );
$role = $request->get_param( 'role' );
$filtered_pricing = array();
// If no filters are applied, return the whole grid (consider security implications).
// For this example, we'll assume we always want to filter by at least the current user's role if no role is specified.
if ( empty( $product_id ) && empty( $role ) ) {
// If no specific filters, try to get pricing for the current user's role.
$current_user = wp_get_current_user();
if ( $current_user && $current_user->roles && is_array( $current_user->roles ) ) {
$role = $current_user->roles[0]; // Use the first role for simplicity.
} else {
// If no role or not logged in (though permission_callback should prevent this), return empty.
return new WP_REST_Response( array( 'message' => 'No pricing found for anonymous users.' ), 403 );
}
}
foreach ( $pricing_grid as $rule ) {
$match_product = true;
if ( ! empty( $product_id ) && isset( $rule['product_id'] ) && $rule['product_id'] !== $product_id ) {
$match_product = false;
}
$match_role = true;
if ( ! empty( $role ) && isset( $rule['role'] ) && $rule['role'] !== $role ) {
$match_role = false;
}
if ( $match_product && $match_role ) {
// Only return relevant fields.
$filtered_pricing[] = array(
'product_id' => $rule['product_id'],
'role' => $rule['role'],
'price' => $rule['price'],
);
}
}
if ( empty( $filtered_pricing ) ) {
return new WP_REST_Response( array( 'message' => 'No pricing found for the specified criteria.' ), 404 );
}
return new WP_REST_Response( $filtered_pricing, 200 );
}
Implementing Role-Based Access Control for Management
Managing pricing grids should not be accessible to all users. We need to restrict who can view and edit these settings. The WordPress role system is ideal for this.
Restricting Access to the Settings Page
If you create a settings page for managing the pricing grid (e.g., using `add_options_page`), you must add a capability check.
/**
* Adds the B2B pricing settings page to the admin menu.
*/
function my_b2b_add_settings_page() {
add_options_page(
__( 'B2B Pricing Grid', 'my-b2b-plugin' ), // Page title
__( 'B2B Pricing', 'my-b2b-plugin' ), // Menu title
'manage_options', // Capability required to access
'b2b-pricing-settings', // Menu slug
'my_b2b_render_settings_page' // Callback function to render the page
);
}
add_action( 'admin_menu', 'my_b2b_add_settings_page' );
/**
* Renders the B2B pricing settings page.
*/
function my_b2b_render_settings_page() {
// Check user capabilities before rendering.
if ( ! current_user_can( 'manage_options' ) ) {
return; // Exit if user doesn't have the capability.
}
// Ensure the option group and option name match those used in register_setting.
?>
<div class="wrap">
<h1><?php echo esc_html( get_admin_page_title() ); ?></h1>
<form action="options.php" method="post">
<?php
settings_fields( 'b2b_pricing_options_group' ); // Matches register_setting()
do_settings_sections( 'b2b-pricing-settings' ); // Matches add_options_page() slug
// Render the pricing grid input field.
$pricing_grid = get_option( 'b2b_pricing_grid', array() );
?>
<table class="form-table">
<tr valign="top">
<th scope="row"><?php _e( 'Pricing Rules', 'my-b2b-plugin' ); ?></th>
<td>
<div id="b2b-pricing-rules-container">
<!-- Pricing rules will be rendered here by JavaScript -->
</div>
<button type="button" id="add-pricing-rule" class="button button-secondary">
<?php _e( 'Add New Rule', 'my-b2b-plugin' ); ?>
</button>
<p class="description">
<?php _e( 'Define pricing tiers based on user roles and product IDs.', 'my-b2b-plugin' ); ?>
</p>
<textarea name="b2b_pricing_grid" id="b2b_pricing_grid_textarea" style="display:none;"></textarea>
</td>
</tr>
</table>
<?php submit_button(); ?>
</form>
</div>
<script type="text/javascript">
// JavaScript for dynamic form rendering will go here.
// This script would parse the JSON from the textarea,
// render input fields for each rule, and update the textarea
// on form submission or changes.
document.addEventListener('DOMContentLoaded', function() {
const container = document.getElementById('b2b-pricing-rules-container');
const textarea = document.getElementById('b2b_pricing_grid_textarea');
let rules = JSON.parse(textarea.value || '[]');
function renderRules() {
container.innerHTML = ''; // Clear existing
rules.forEach((rule, index) => {
const ruleDiv = document.createElement('div');
ruleDiv.style.marginBottom = '20px';
ruleDiv.style.border = '1px solid #ccc';
ruleDiv.style.padding = '10px';
ruleDiv.innerHTML = `
<h4><?php _e( 'Rule', 'my-b2b-plugin' ); ?> ${index + 1}</h4>
<label><?php _e( 'User Role', 'my-b2b-plugin' ); ?>:</label>
<input type="text" name="b2b_pricing_grid[${index}][role]" value="${rule.role || ''}" required /><br />
<label><?php _e( 'Product ID', 'my-b2b-plugin' ); ?>:</label>
<input type="number" name="b2b_pricing_grid[${index}][product_id]" value="${rule.product_id || ''}" required /><br />
<label><?php _e( 'Price', 'my-b2b-plugin' ); ?>:</label>
<input type="number" step="0.01" name="b2b_pricing_grid[${index}][price]" value="${rule.price || ''}" required /><br />
<button type="button" class="remove-pricing-rule button button-secondary" data-index="${index}"><?php _e( 'Remove Rule', 'my-b2b-plugin' ); ?></button>
`;
container.appendChild(ruleDiv);
});
// Add event listeners for remove buttons
document.querySelectorAll('.remove-pricing-rule').forEach(button => {
button.addEventListener('click', function() {
const indexToRemove = parseInt(this.getAttribute('data-index'));
rules.splice(indexToRemove, 1);
updateTextarea();
renderRules();
});
});
}
function updateTextarea() {
textarea.value = JSON.stringify(rules);
}
document.getElementById('add-pricing-rule').addEventListener('click', function() {
rules.push({ role: '', product_id: '', price: '' });
updateTextarea();
renderRules();
});
// Initial render
renderRules();
// Update textarea when form is submitted (or on change, more complex)
// For simplicity, we'll rely on the form submission to update the textarea.
// A more advanced solution would use event listeners for input changes.
document.querySelector('form').addEventListener('submit', function() {
updateTextarea();
});
});
</script>
<?php _e( 'Configure your B2B pricing rules below.', 'my-b2b-plugin' ); ?></p>';
}
/**
* Callback for the pricing grid settings field.
* This function is primarily a placeholder as the actual input is managed by JS.
* The 'b2b_pricing_grid' textarea in render_settings_page is what gets submitted.
*/
function my_b2b_field_callback() {
// The actual input is handled by the textarea in my_b2b_render_settings_page.
// This callback is mainly to satisfy the settings API structure.
// We could echo a hidden input here if needed, but the textarea is more direct.
echo '<p><?php _e( 'Pricing rules are managed dynamically. Ensure JavaScript is enabled.', 'my-b2b-plugin' ); ?></p>';
}
In the code above:
- We use
add_options_pageto create a submenu item under ‘Settings’. - The
'manage_options'capability is crucial. Only users with this capability (typically Administrators) can access the page. You can define custom capabilities for more granular control. - The settings page renders a form that uses
settings_fields()anddo_settings_sections(), which are part of the WordPress Settings API. - A JavaScript-driven interface is used to dynamically add, edit, and remove pricing rules. The current state of these rules is stored in a JSON-encoded string within a hidden textarea, which is then submitted to the `b2b_pricing_grid` option.
- The `my_b2b_sanitize_pricing_grid` function is vital for cleaning the incoming data before it’s saved to the database.
Custom Capabilities for Advanced Role Management
For more sophisticated access control, you might want to define custom capabilities. For instance, a role like ‘Pricing Manager’ could be created with a specific capability like 'manage_b2b_pricing'.
/**
* Add custom capability to a role on plugin activation.
*/
function my_b2b_add_custom_capabilities() {
$role = get_role( 'administrator' ); // Example: Add to administrator role
if ( $role ) {
$role->add_cap( 'manage_b2b_pricing' );
}
// You could also create a new role and add capabilities to it.
// add_role( 'pricing_manager', __( 'Pricing Manager', 'my-b2b-plugin' ), array( 'read' => true, 'manage_b2b_pricing' => true ) );
}
register_activation_hook( __FILE__, 'my_b2b_add_custom_capabilities' );
/**
* Remove custom capability on plugin deactivation.
*/
function my_b2b_remove_custom_capabilities() {
$role = get_role( 'administrator' );
if ( $role ) {
$role->remove_cap( 'manage_b2b_pricing' );
}
// Remove custom role if created.
// remove_role( 'pricing_manager' );
}
register_deactivation_hook( __FILE__, 'my_b2b_remove_custom_capabilities' );
Then, in your `add_options_page` call, you would use 'manage_b2b_pricing' instead of 'manage_options'. For the REST API, you’d modify the permission_callback:
// Inside my_b2b_register_api_routes function:
// ...
register_rest_route( $namespace, $route, array(
'methods' => WP_REST_Server::READABLE,
'callback' => 'my_b2b_get_pricing_data',
'permission_callback' => function() {
// Check for the custom capability.
return current_user_can( 'manage_b2b_pricing' );
},
// ... other args
) );
// ...
Client-Side Implementation: Fetching and Applying Prices
On the frontend, you’ll use JavaScript to fetch pricing data from your custom endpoint and apply it to products. This typically involves making an AJAX request to your WordPress REST API.
document.addEventListener('DOMContentLoaded', function() {
// Function to get the current user's role (requires passing this data from PHP or using WP REST API user endpoint)
// For simplicity, let's assume we have a global JS variable or can fetch it.
// A more robust solution would involve fetching user data via REST API.
const currentUserRole = typeof myB2BData !== 'undefined' ? myB2BData.currentUserRole : null; // Example: myB2BData = { currentUserRole: 'subscriber' };
function getB2BPrice(productId, callback) {
if (!currentUserRole) {
console.warn('User role not available. Cannot fetch B2B price.');
callback(null);
return;
}
const endpoint = `/wp-json/my-b2b/v1/pricing?product_id=${productId}&role=${currentUserRole}`;
fetch(endpoint)
.then(response => {
if (!response.ok) {
// If 404 or other error, it might mean no specific B2B price is set for this role/product.
// Fallback to default price or handle as needed.
if (response.status === 404) {
console.log(`No specific B2B pricing found for product ${productId} and role ${currentUserRole}.`);
callback(null); // Indicate no B2B price found
} else {
throw new Error(`HTTP error! status: ${response.status}`);
}
}
return response.json();
})
.then(data => {
if (data && data.length > 0) {
// Assuming the API returns an array, take the first match.
// You might need more complex logic if multiple rules match.
const priceRule = data.find(rule => rule.product_id == productId && rule.role === currentUserRole);
if (priceRule) {
callback(priceRule.price);
} else {
callback(null); // No matching rule found in the response
}
} else {
callback(null); // No pricing data returned
}
})
.catch(error => {
console.error('Error fetching B2B price:', error);
callback(null); // Indicate error
});
}
// Example usage: Apply price to a product element
// Assume you have elements like:
// <div class="product" data-product-id="123">
// <span class="product-price">$99.99</span>
// </div>
document.querySelectorAll('.product').forEach(productElement => {
const productId = productElement.getAttribute('data-product-id');
if (productId) {
getB2BPrice(productId, function(b2bPrice) {
if (b2bPrice !== null) {
const priceDisplayElement = productElement.querySelector('.product-price');
if (priceDisplayElement) {
// Format price as needed (e.g., with currency symbol)
priceDisplayElement.textContent = `$${parseFloat(b2bPrice).toFixed(2)}`;
// Optionally add a class to indicate it's a B2B price
priceDisplayElement.classList.add('b2b-price');
}
}
// If b2bPrice is null, the default price remains.
});
}
});
});
To make currentUserRole available in JavaScript, you would typically enqueue a script and pass data using wp_localize_script:
/**
* Enqueues frontend scripts and localizes data.
*/
function my_b2b_enqueue_frontend_scripts() {
// Only load on pages where pricing might be displayed.
// You might want to add more specific checks here.
if ( ! is_user_logged_in() ) {
return;
}
wp_enqueue_script(
'my-b2b-pricing-script',
plugin_dir_url( __FILE__ ) . 'js/b2b-pricing.js', // Path to your JS file
array( 'wp-api' ), // Dependency on WP REST API scripts
'1.0.0',
true // Load in footer
);
// Get current user's role.
$current_user = wp_get_current_user();
$user_role = '';
if ( $current_user && $current_user->roles && is_array( $current_user->roles ) ) {
$user_role = $current_user->roles[0]; // Use the first role
}
wp_localize_script(
'my-b2b-pricing-script',
'myB2BData',
array(
'currentUserRole' => $user_role,
// Add other data if needed, e.g., API nonce for authenticated requests
)
);
}
add_action( 'wp_enqueue_scripts', 'my_b2b_enqueue_frontend_scripts' );
Security Considerations and Best Practices
When dealing with pricing data, security is paramount. Always adhere to these principles:
- Sanitization: Thoroughly sanitize all data before saving it to the database (as demonstrated with
my_b2b_sanitize_pricing_grid) and before outputting it to the screen. - Validation: Validate incoming data against expected formats and types. The REST API
argsarray helps with this. - Capability Checks: Implement strict capability checks for both the admin settings page and the REST API endpoints. Use custom capabilities for fine-grained control.
- Authentication: Ensure that sensitive endpoints require user authentication. The
is_user_logged_in()check is a minimum; consider checking specific roles or capabilities. - Data Exposure: Be mindful of what data is exposed via the REST API. Only return necessary fields. Avoid exposing internal IDs or sensitive configuration details unless required.
- Nonce Verification: For any REST API endpoints that perform write operations (e.g., updating pricing), always use nonces to prevent CSRF attacks.
- HTTPS: Always use HTTPS to encrypt data in transit.
By combining the WordPress Options API, custom REST API endpoints, and robust role-based access control, you can build a secure, flexible, and scalable B2B pricing grid system within your WordPress site.