Skip to content
Merged
Show file tree
Hide file tree
Changes from 17 commits
Commits
Show all changes
35 commits
Select commit Hold shift + click to select a range
6d79778
Move tracking opt-in to general settings and onboarding wizard
superdav42 Jan 25, 2026
c101dc4
better error logging and handling of user accounts unable to connect …
superdav42 Jan 25, 2026
0f94b89
add recommended html attr
superdav42 Jan 25, 2026
a1df46f
allow kses to be extended
superdav42 Jan 25, 2026
d0bc595
add settings api
superdav42 Jan 25, 2026
58d27e5
Fix problems with choosing country and state
superdav42 Jan 25, 2026
9998c00
Fix login redirect
superdav42 Jan 25, 2026
a5b944d
Add support for pay what you want prices
superdav42 Jan 29, 2026
ab41443
Add support for pay what you want prices
superdav42 Jan 29, 2026
e2bc76d
Add support for pay what you want prices
superdav42 Jan 29, 2026
a808f21
set duration for manually created memberships
superdav42 Jan 29, 2026
30f1ac2
add duration restrictions for discount codes
superdav42 Jan 29, 2026
af6276c
make yoursite translatable
superdav42 Jan 29, 2026
a8b1edb
let users change their email
superdav42 Jan 29, 2026
28692a2
let networks login
superdav42 Jan 29, 2026
d905829
fix error in multinetwork
superdav42 Jan 29, 2026
76e4874
fix extra chars
superdav42 Jan 29, 2026
f4ca4ec
Apply suggestion from @superdav42
superdav42 Jan 30, 2026
93dd859
Fix product tabs
superdav42 Feb 2, 2026
eb12793
Skip billing requirements for free trials, fix null product handling,…
superdav42 Feb 3, 2026
a3b1bfd
Skip billing validation when payment not required, code style fixes
superdav42 Feb 3, 2026
d181e94
Apply suggestion from @coderabbitai[bot]
superdav42 Feb 3, 2026
1d66ab8
Address CodeRabbit review feedback and refactor Settings API
superdav42 Feb 3, 2026
e4537be
Merge branch 'main' into tweask-again
superdav42 Feb 3, 2026
c65339c
Enforce a strong password
superdav42 Feb 3, 2026
a76cee6
Merge branch 'tweask-again' of github.com:Multisite-Ultimate/ultimate…
superdav42 Feb 3, 2026
e95460b
Restructure E2E tests with programmatic setup and working checkout flows
superdav42 Feb 3, 2026
4655401
Fix PHPCS violations in E2E PHP fixtures
superdav42 Feb 3, 2026
08f14d0
Fix e2e workflow: correct spec names, remove invalid CLI flags, fix e…
superdav42 Feb 4, 2026
0ba2b71
fix searches
superdav42 Feb 4, 2026
01cbcb6
Add the doc
superdav42 Feb 4, 2026
3a09c64
fix legacy
superdav42 Feb 4, 2026
7dda86b
Use correct logic
superdav42 Feb 4, 2026
98ef974
Update test matrix to require PHP 8.2 minimum
superdav42 Feb 4, 2026
b50502b
Merge branch 'main' into tweask-again
superdav42 Feb 4, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
26 changes: 26 additions & 0 deletions assets/js/checkout.js
Original file line number Diff line number Diff line change
Expand Up @@ -173,6 +173,8 @@
logging_in: false,
login_error: '',
inline_login_password: '',
custom_amounts: wu_checkout.custom_amounts || {},
pwyw_recurring: wu_checkout.pwyw_recurring || {},
};

hooks.applyFilters('wu_before_form_init', initial_data);
Expand Down Expand Up @@ -413,6 +415,30 @@

return this.products.indexOf(product_id) > -1 || this.products.indexOf(parseInt(product_id, 10)) > -1;

},
set_custom_amount(product_id, amount) {

Vue.set(this.custom_amounts, product_id, parseFloat(amount) || 0);

this.create_order();

},
get_custom_amount(product_id) {

return this.custom_amounts[product_id] || null;

},
set_pwyw_recurring(product_id, is_recurring) {

Vue.set(this.pwyw_recurring, product_id, Boolean(is_recurring));

this.create_order();

},
get_pwyw_recurring(product_id) {

return this.pwyw_recurring[product_id] || false;

},
wu_format_money(value) {

Expand Down
228 changes: 225 additions & 3 deletions inc/admin-pages/class-discount-code-edit-admin-page.php
Original file line number Diff line number Diff line change
Expand Up @@ -113,7 +113,7 @@ public function register_widgets(): void {
$tz_note = sprintf('The site timezone is <code>%s</code>. The current time is <code>%s</code>', date_i18n('e'), date_i18n('r'));

$options = [
'general' => [
'general' => [
'title' => __('Limit Uses', 'ultimate-multisite'),
'icon' => 'dashicons-wu-lock',
'desc' => __('Rules and limitations to the applicability of this discount code.', 'ultimate-multisite'),
Expand All @@ -135,7 +135,7 @@ public function register_widgets(): void {
],
],
],
'time' => [
'time' => [
'title' => __('Start & Expiration Dates', 'ultimate-multisite'),
'desc' => __('Define a start and end date for this discount code. Useful when running campaigns for a pre-determined period.', 'ultimate-multisite'),
'icon' => 'dashicons-wu-calendar',
Expand Down Expand Up @@ -200,7 +200,7 @@ public function register_widgets(): void {
],
],
],
'products' => [
'products' => [
'title' => __('Limit Products', 'ultimate-multisite'),
'desc' => __('Determine if you want this discount code to apply to all discountable products or not.', 'ultimate-multisite'),
'icon' => 'dashicons-wu-price-tag',
Expand All @@ -222,6 +222,28 @@ public function register_widgets(): void {
$this->get_product_field_list()
),
],
'billing_periods' => [
'title' => __('Limit Billing Periods', 'ultimate-multisite'),
'desc' => __('Restrict this discount code to specific billing periods (e.g., only monthly or only annual plans).', 'ultimate-multisite'),
'icon' => 'dashicons-wu-calendar',
'state' => [
'limit_billing_periods' => $this->get_object()->get_limit_billing_periods(),
],
'fields' => array_merge(
[
'limit_billing_periods' => [
'type' => 'toggle',
'title' => __('Select Billing Periods', 'ultimate-multisite'),
'desc' => __('Manually select which billing periods this discount code should be applicable to.', 'ultimate-multisite'),
'value' => 1,
'html_attr' => [
'v-model' => 'limit_billing_periods',
],
],
],
$this->get_billing_period_field_list()
),
],
];

$this->add_tabs_widget(
Expand Down Expand Up @@ -443,6 +465,190 @@ protected function get_product_field_list() {
return $fields;
}

/**
* List of billing periods to apply this coupon to.
*
* @since 2.0.0
* @return array
*/
protected function get_billing_period_field_list() {

$fields = [];
$billing_periods = $this->get_available_billing_periods();
$allowed_periods = $this->get_object()->get_allowed_billing_periods();
$limit_periods = $this->get_object()->get_limit_billing_periods();

foreach ($billing_periods as $period_key => $period_label) {
$fields[ "allowed_billing_periods_{$period_key}" ] = [
'type' => 'toggle',
'title' => $period_label,
'desc' => __('Make applicable to this billing period.', 'ultimate-multisite'),
'tooltip' => '',
'wrapper_classes' => '',
'html_attr' => [
':name' => "'allowed_billing_periods[]'",
':checked' => wp_json_encode(! $limit_periods || in_array($period_key, $allowed_periods, true)),
':value' => wp_json_encode($period_key),
],
'wrapper_html_attr' => [
'v-cloak' => 1,
'v-show' => 'limit_billing_periods',
],
];
}

// Hidden field to ensure at least one value is submitted
$fields['allowed_billing_periods_none'] = [
'type' => 'hidden',
'value' => '__none',
'html_attr' => [
':name' => "'allowed_billing_periods[]'",
],
];

if (empty($billing_periods)) {
$fields['allowed_billing_periods_no_periods'] = [
'type' => 'note',
'title' => '',
'desc' => __('No billing periods found. Create products with different billing periods first.', 'ultimate-multisite'),
'wrapper_html_attr' => [
'v-cloak' => 1,
'v-show' => 'limit_billing_periods',
],
];
}

return $fields;
}

/**
* Get all available billing periods from products.
*
* Scans all products to find unique billing period combinations.
*
* @since 2.0.0
* @return array Associative array of period_key => label.
*/
protected function get_available_billing_periods() {

$periods = [];

foreach (wu_get_products() as $product) {
if ( ! $product->is_recurring()) {
continue;
}

$duration = $product->get_duration();
$duration_unit = $product->get_duration_unit();
$period_key = Discount_Code::get_billing_period_key($duration, $duration_unit);

if ( ! isset($periods[ $period_key ])) {
$periods[ $period_key ] = $this->format_billing_period_label($duration, $duration_unit);
}

// Also check for price variations
$price_variations = $product->get_price_variations();

if ( ! empty($price_variations)) {
foreach ($price_variations as $variation) {
$var_duration = isset($variation['duration']) ? (int) $variation['duration'] : 0;
$var_duration_unit = isset($variation['duration_unit']) ? $variation['duration_unit'] : '';

if ($var_duration > 0 && ! empty($var_duration_unit)) {
$var_period_key = Discount_Code::get_billing_period_key($var_duration, $var_duration_unit);

if ( ! isset($periods[ $var_period_key ])) {
$periods[ $var_period_key ] = $this->format_billing_period_label($var_duration, $var_duration_unit);
}
}
}
}
}

// Sort by duration for consistent display
uksort(
$periods,
function ($a, $b) {
$a_parts = Discount_Code::parse_billing_period_key($a);
$b_parts = Discount_Code::parse_billing_period_key($b);

if ( ! $a_parts || ! $b_parts) {
return 0;
}

// Convert to days for comparison
$a_days = $this->get_period_in_days($a_parts['duration'], $a_parts['duration_unit']);
$b_days = $this->get_period_in_days($b_parts['duration'], $b_parts['duration_unit']);

return $a_days <=> $b_days;
}
);

return $periods;
}
Comment on lines +532 to +588
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🔴 Critical

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Search for is_recurring, pay_what_you_want, and pwyw patterns
rg -n "is_recurring\(|pay_what_you_want|pwyw_recurring_mode" -A 2 -B 2

Repository: Ultimate-Multisite/ultimate-multisite

Length of output: 33826


🏁 Script executed:

# Also search for the Product class definition and is_recurring method
rg -n "class.*Product|function is_recurring|def is_recurring" -A 5 -B 2

Repository: Ultimate-Multisite/ultimate-multisite

Length of output: 14004


🏁 Script executed:

# Check how PWYW products are represented and handled
rg -n "is_pay_what_you_want|pwyw|pay.what.you.want" -i -A 3 -B 2

Repository: Ultimate-Multisite/ultimate-multisite

Length of output: 30256


🏁 Script executed:

# Search for how get_available_billing_periods is used
rg -n "get_available_billing_periods" -B 2 -A 5

Repository: Ultimate-Multisite/ultimate-multisite

Length of output: 1616


🏁 Script executed:

# Check if discount codes have special handling for PWYW products
rg -n "class.*Discount.*Code|is_pay_what_you_want.*discount" -i -A 3 -B 2

Repository: Ultimate-Multisite/ultimate-multisite

Length of output: 50394


🏁 Script executed:

# Look at the discount code model to understand what billing periods are used for
fd "class-discount" --type f | head -5 | xargs cat

Repository: Ultimate-Multisite/ultimate-multisite

Length of output: 36757


PWYW recurring products with force_recurring mode won't appear in available billing periods.

The method relies on is_recurring() which checks the recurring flag and amount > 0, but doesn't account for pwyw_recurring_mode. A PWYW product configured with force_recurring may have recurring=false, causing is_recurring() to return false and the product to be skipped entirely. This means its billing period information never reaches the discount code admin form, preventing admins from restricting codes to periods that only exist on PWYW products.

🤖 Prompt for AI Agents
In `@inc/admin-pages/class-discount-code-edit-admin-page.php` around lines 532 -
588, get_available_billing_periods currently skips products based solely on
$product->is_recurring(), which misses PWYW products with pwyw_recurring_mode ==
'force_recurring'; update the recurrence check in get_available_billing_periods
to treat a product as recurring if $product->is_recurring() OR
($product->get_pwyw_recurring_mode() === 'force_recurring') (or the equivalent
accessor/name used on the Product class), then continue using
$product->get_duration(), $product->get_duration_unit(), get_price_variations(),
and Discount_Code::get_billing_period_key/parse_billing_period_key as before so
PWYW forced-recurring products are included in the $periods list and sorting.


/**
* Format a billing period label for display.
*
* @since 2.0.0
* @param int $duration The billing duration.
* @param string $duration_unit The billing duration unit.
* @return string Human-readable label.
*/
protected function format_billing_period_label(int $duration, string $duration_unit): string {

$unit_labels = [
'day' => [
'singular' => __('Day', 'ultimate-multisite'),
'plural' => __('Days', 'ultimate-multisite'),
],
'week' => [
'singular' => __('Week', 'ultimate-multisite'),
'plural' => __('Weeks', 'ultimate-multisite'),
],
'month' => [
'singular' => __('Month', 'ultimate-multisite'),
'plural' => __('Months', 'ultimate-multisite'),
],
'year' => [
'singular' => __('Year', 'ultimate-multisite'),
'plural' => __('Years', 'ultimate-multisite'),
],
];

$unit_label = isset($unit_labels[ $duration_unit ])
? (1 === $duration ? $unit_labels[ $duration_unit ]['singular'] : $unit_labels[ $duration_unit ]['plural'])
: $duration_unit;

if (1 === $duration) {
return $unit_label;
}

return sprintf('%d %s', $duration, $unit_label);
}

/**
* Convert a billing period to days for sorting purposes.
*
* @since 2.0.0
* @param int $duration The billing duration.
* @param string $duration_unit The billing duration unit.
* @return int Approximate number of days.
*/
protected function get_period_in_days(int $duration, string $duration_unit): int {

$multipliers = [
'day' => 1,
'week' => 7,
'month' => 30,
'year' => 365,
];

$multiplier = isset($multipliers[ $duration_unit ]) ? $multipliers[ $duration_unit ] : 1;

return $duration * $multiplier;
}

/**
* Handles legacy advanced options for coupons.
*
Expand Down Expand Up @@ -655,6 +861,22 @@ public function handle_save(): void {
$_POST['limit_products'] = false;
}

/*
* Set the limit billing periods value.
*/
if ( ! wu_request('limit_billing_periods')) {
$_POST['limit_billing_periods'] = false;
}

/*
* Filter out the placeholder value from allowed_billing_periods.
*/
$allowed_billing_periods = wu_request('allowed_billing_periods', []);

if (is_array($allowed_billing_periods)) {
$_POST['allowed_billing_periods'] = array_filter($allowed_billing_periods, fn($value) => '__none' !== $value);
}

/*
* Set the setup fee value to zero if the toggle is disabled.
*/
Expand Down
36 changes: 34 additions & 2 deletions inc/admin-pages/class-membership-list-admin-page.php
Original file line number Diff line number Diff line change
Expand Up @@ -113,6 +113,23 @@ public function render_add_new_membership_modal(): void {
'data-max-items' => 99,
],
],
'billing_period' => [
'type' => 'select',
'title' => __('Billing Period', 'ultimate-multisite'),
'desc' => __('Select the billing period for this membership. Must match a price variation in the selected product.', 'ultimate-multisite'),
'tooltip' => '',
'value' => '1-month',
'options' => [
'1-day' => __('Daily', 'ultimate-multisite'),
'1-week' => __('Weekly', 'ultimate-multisite'),
'1-month' => __('Monthly', 'ultimate-multisite'),
'3-month' => __('Quarterly (3 months)', 'ultimate-multisite'),
'6-month' => __('Semi-annually (6 months)', 'ultimate-multisite'),
'1-year' => __('Yearly', 'ultimate-multisite'),
'2-year' => __('Every 2 years', 'ultimate-multisite'),
'3-year' => __('Every 3 years', 'ultimate-multisite'),
],
],
'status' => [
'type' => 'select',
'title' => __('Status', 'ultimate-multisite'),
Expand Down Expand Up @@ -213,13 +230,28 @@ public function handle_add_new_membership_modal(): void {
);
}

// Parse the billing period into duration and duration_unit.
$billing_period = wu_request('billing_period', '1-month');
$billing_parts = explode('-', $billing_period, 2);
$duration = isset($billing_parts[0]) ? absint($billing_parts[0]) : 1;
$duration_unit = isset($billing_parts[1]) ? $billing_parts[1] : 'month';
Comment on lines +233 to +237
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Validate and clamp the billing period inputs before building the cart.
billing_period comes from the request; absint() can yield 0, and duration_unit isn’t validated. This can lead to invalid variations or avoidable cart errors. Consider whitelisting units and enforcing duration >= 1.

✅ Suggested hardening
- $billing_period = wu_request('billing_period', '1-month');
- $billing_parts  = explode('-', $billing_period, 2);
- $duration       = isset($billing_parts[0]) ? absint($billing_parts[0]) : 1;
- $duration_unit  = isset($billing_parts[1]) ? $billing_parts[1] : 'month';
+ $billing_period = wu_request('billing_period', '1-month');
+ $billing_parts  = explode('-', $billing_period, 2);
+ $duration       = max(1, absint($billing_parts[0] ?? 1));
+ $duration_unit  = $billing_parts[1] ?? 'month';
+ $allowed_units  = ['day', 'week', 'month', 'year'];
+ if ( ! in_array($duration_unit, $allowed_units, true)) {
+     $duration_unit = 'month';
+ }
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
// Parse the billing period into duration and duration_unit.
$billing_period = wu_request('billing_period', '1-month');
$billing_parts = explode('-', $billing_period, 2);
$duration = isset($billing_parts[0]) ? absint($billing_parts[0]) : 1;
$duration_unit = isset($billing_parts[1]) ? $billing_parts[1] : 'month';
// Parse the billing period into duration and duration_unit.
$billing_period = wu_request('billing_period', '1-month');
$billing_parts = explode('-', $billing_period, 2);
$duration = max(1, absint($billing_parts[0] ?? 1));
$duration_unit = $billing_parts[1] ?? 'month';
$allowed_units = ['day', 'week', 'month', 'year'];
if ( ! in_array($duration_unit, $allowed_units, true)) {
$duration_unit = 'month';
}
🤖 Prompt for AI Agents
In `@inc/admin-pages/class-membership-list-admin-page.php` around lines 233 - 237,
The billing_period parsing accepts untrusted input: ensure $duration is clamped
to >=1 and $duration_unit is validated against a whitelist before using it to
build the cart; specifically, after parsing $billing_period (the code that sets
$billing_parts, $duration and $duration_unit) replace the loose values with a
sanitized $duration = max(1, absint($billing_parts[0] ?? 1)) and $duration_unit
only set if in an allowed array (e.g. ['day','week','month','year']) otherwise
default to 'month' so invalid units or zero durations cannot create bad
variations in the cart.


$cart = new \WP_Ultimo\Checkout\Cart(
[
'products' => $products,
'country' => $customer->get_country(),
'products' => $products,
'country' => $customer->get_country(),
'duration' => $duration,
'duration_unit' => $duration_unit,
]
);

// Check for cart errors (e.g., missing price variations).
$cart_errors = $cart->get_errors();

if ($cart_errors->has_errors()) {
wp_send_json_error($cart_errors);
}

$data = $cart->to_membership_data();

$data['customer_id'] = $customer->get_id();
Expand Down
Loading
Loading