diff --git a/projects/plugins/jetpack/changelog/add-donations-security-min-max-amounts b/projects/plugins/jetpack/changelog/add-donations-security-min-max-amounts
new file mode 100644
index 000000000000..0e88e7fcac68
--- /dev/null
+++ b/projects/plugins/jetpack/changelog/add-donations-security-min-max-amounts
@@ -0,0 +1,4 @@
+Significance: patch
+Type: enhancement
+
+Donations Block: Add Security inspector panel with configurable minimum and maximum donation amounts to help prevent fraudulent transactions.
diff --git a/projects/plugins/jetpack/extensions/blocks/donations/block.json b/projects/plugins/jetpack/extensions/blocks/donations/block.json
index eea62474b434..2ac0c242e4da 100644
--- a/projects/plugins/jetpack/extensions/blocks/donations/block.json
+++ b/projects/plugins/jetpack/extensions/blocks/donations/block.json
@@ -174,6 +174,12 @@
"type": "string",
"enum": [ "", "left", "center", "right" ],
"default": ""
+ },
+ "minimumAmount": {
+ "type": "number"
+ },
+ "maximumAmount": {
+ "type": "number"
}
},
"example": {}
diff --git a/projects/plugins/jetpack/extensions/blocks/donations/controls.js b/projects/plugins/jetpack/extensions/blocks/donations/controls.js
index 4033e0cb20c1..37215cfdd520 100644
--- a/projects/plugins/jetpack/extensions/blocks/donations/controls.js
+++ b/projects/plugins/jetpack/extensions/blocks/donations/controls.js
@@ -19,7 +19,7 @@ import {
ToolbarButton,
} from '@wordpress/components';
import { useCallback } from '@wordpress/element';
-import { __ } from '@wordpress/i18n';
+import { __, sprintf } from '@wordpress/i18n';
import { DOWN } from '@wordpress/keycodes';
import {
getDefaultDonationAmountsForCurrency,
@@ -45,9 +45,12 @@ const Controls = props => {
contentAlignment,
defaultInterval,
customAmountPlaceholder,
+ minimumAmount,
+ maximumAmount,
} = attributes;
- const computedCustomAmountPlaceholder = minimumTransactionAmountForCurrency( currency ) * 100;
+ const stripeMin = minimumTransactionAmountForCurrency( currency );
+ const computedCustomAmountPlaceholder = stripeMin * 100;
const effectiveCustomAmountPlaceholder =
customAmountPlaceholder ?? computedCustomAmountPlaceholder;
@@ -130,6 +133,21 @@ const Controls = props => {
[ setAttributes ]
);
+ let maximumHelp;
+ if (
+ minimumAmount !== undefined &&
+ maximumAmount !== undefined &&
+ maximumAmount < minimumAmount
+ ) {
+ maximumHelp = __( 'Maximum must be greater than the minimum amount.', 'jetpack' );
+ } else if ( maximumAmount !== undefined && maximumAmount < stripeMin ) {
+ maximumHelp = sprintf(
+ /* translators: %s: minimum donation amount formatted with currency symbol */
+ __( 'Maximum must be at least %s, the minimum amount Stripe can process.', 'jetpack' ),
+ formatCurrency( stripeMin, currency )
+ );
+ }
+
const changeDefaultDonationAmounts = ccy => {
const defaultAmounts = getDefaultDonationAmountsForCurrency( ccy );
@@ -331,6 +349,41 @@ const Controls = props => {
+
+
+ { __(
+ 'Setting minimum and maximum donation amounts can help prevent fraudulent transactions.',
+ 'jetpack'
+ ) }
+
+
+ setAttributes( {
+ minimumAmount: value === '' ? undefined : Number( value ),
+ } )
+ }
+ min={ stripeMin }
+ step={ 0.01 }
+ __nextHasNoMarginBottom={ true }
+ />
+
+ setAttributes( {
+ maximumAmount: value === '' ? undefined : Number( value ),
+ } )
+ }
+ min={ minimumAmount ?? stripeMin }
+ step={ 0.01 }
+ help={ maximumHelp }
+ __nextHasNoMarginBottom={ true }
+ />
+
>
);
diff --git a/projects/plugins/jetpack/extensions/blocks/donations/donations.php b/projects/plugins/jetpack/extensions/blocks/donations/donations.php
index e8cf430c37d9..c8ee57f2667a 100644
--- a/projects/plugins/jetpack/extensions/blocks/donations/donations.php
+++ b/projects/plugins/jetpack/extensions/blocks/donations/donations.php
@@ -254,8 +254,9 @@ function render_block( $attr, $content ) {
if ( $default_interval ) {
$wrapper_attr_array['data-default-interval'] = $default_interval;
}
- $wrapper_attrs = get_block_wrapper_attributes( $wrapper_attr_array );
- $custom_styles = build_custom_styles( $attr, '.' . $instance_id );
+ $wrapper_attr_array = array_merge( $wrapper_attr_array, build_security_data_attrs( $attr, $currency ) );
+ $wrapper_attrs = get_block_wrapper_attributes( $wrapper_attr_array );
+ $custom_styles = build_custom_styles( $attr, '.' . $instance_id );
$choose_amount_html = wp_kses_post( $choose_amount_text );
$choose_amount_block = '' !== trim( $choose_amount_html ) ? '' . $choose_amount_html . '
' : '';
@@ -271,6 +272,7 @@ function render_block( $attr, $content ) {
%4$s
%5$s
%6$s
+
%7$s
%8$s
@@ -292,6 +294,46 @@ function render_block( $attr, $content ) {
);
}
+/**
+ * Build data-attributes array for security (min/max amount) constraints.
+ *
+ * Extracted so it can be tested independently of the full render pipeline.
+ *
+ * @since $$next-version$$
+ *
+ * @param array $attr Block attributes.
+ * @param string $currency Currency code (e.g. 'USD').
+ * @return array Associative array of data-attribute name => value.
+ */
+function build_security_data_attrs( $attr, $currency ) {
+ $attrs = array();
+ $min_amount = isset( $attr['minimumAmount'] ) ? (float) $attr['minimumAmount'] : null;
+ $max_amount = isset( $attr['maximumAmount'] ) ? (float) $attr['maximumAmount'] : null;
+ if ( null !== $min_amount ) {
+ $attrs['data-min-amount'] = $min_amount;
+ $attrs['data-min-error'] = sprintf(
+ /* translators: %s: minimum donation amount formatted with currency symbol */
+ __( 'The minimum donation amount is %s.', 'jetpack' ),
+ \Jetpack_Currencies::format_price( $min_amount, $currency )
+ );
+ }
+ if ( null !== $max_amount ) {
+ $attrs['data-max-amount'] = $max_amount;
+ $attrs['data-max-error'] = sprintf(
+ /* translators: %s: maximum donation amount formatted with currency symbol */
+ __( 'The maximum donation amount is %s.', 'jetpack' ),
+ \Jetpack_Currencies::format_price( $max_amount, $currency )
+ );
+ }
+ $stripe_min = \Jetpack_Memberships::SUPPORTED_CURRENCIES[ $currency ] ?? 1;
+ $attrs['data-stripe-min-error'] = sprintf(
+ /* translators: %s: payment processor minimum donation amount formatted with currency symbol */
+ _x( 'The minimum donation amount is %s.', 'payment processor minimum', 'jetpack' ),
+ \Jetpack_Currencies::format_price( $stripe_min, $currency )
+ );
+ return $attrs;
+}
+
/**
* Build a CSS string scoping per-state and tab-level style rules to a single
* block instance.
diff --git a/projects/plugins/jetpack/extensions/blocks/donations/test/utils.test.js b/projects/plugins/jetpack/extensions/blocks/donations/test/utils.test.js
new file mode 100644
index 000000000000..6359d071bb6b
--- /dev/null
+++ b/projects/plugins/jetpack/extensions/blocks/donations/test/utils.test.js
@@ -0,0 +1,60 @@
+import { checkAmountRange, firstShownInterval } from '../utils';
+
+describe( 'firstShownInterval', () => {
+ test( 'returns one-time when it is shown', () => {
+ expect( firstShownInterval( true, true, true ) ).toBe( 'one-time' );
+ expect( firstShownInterval( true, false, false ) ).toBe( 'one-time' );
+ } );
+
+ test( 'skips one-time and returns monthly when one-time is hidden', () => {
+ expect( firstShownInterval( false, true, true ) ).toBe( '1 month' );
+ expect( firstShownInterval( false, true, false ) ).toBe( '1 month' );
+ } );
+
+ test( 'returns annual when only annual is shown', () => {
+ expect( firstShownInterval( false, false, true ) ).toBe( '1 year' );
+ } );
+
+ test( 'returns null when all are hidden', () => {
+ expect( firstShownInterval( false, false, false ) ).toBeNull();
+ } );
+} );
+
+describe( 'checkAmountRange', () => {
+ const MIN_ERROR = 'Minimum is $10.';
+ const MAX_ERROR = 'Maximum is $100.';
+
+ test( 'returns null when no limits are set', () => {
+ expect( checkAmountRange( 50, null, null, MIN_ERROR, MAX_ERROR ) ).toBeNull();
+ } );
+
+ test( 'returns null when amount is within range', () => {
+ expect( checkAmountRange( 50, 10, 100, MIN_ERROR, MAX_ERROR ) ).toBeNull();
+ } );
+
+ test( 'returns null when amount equals the minimum', () => {
+ expect( checkAmountRange( 10, 10, 100, MIN_ERROR, MAX_ERROR ) ).toBeNull();
+ } );
+
+ test( 'returns null when amount equals the maximum', () => {
+ expect( checkAmountRange( 100, 10, 100, MIN_ERROR, MAX_ERROR ) ).toBeNull();
+ } );
+
+ test( 'returns minError when amount is below minimum', () => {
+ expect( checkAmountRange( 5, 10, 100, MIN_ERROR, MAX_ERROR ) ).toBe( MIN_ERROR );
+ } );
+
+ test( 'returns maxError when amount is above maximum', () => {
+ expect( checkAmountRange( 150, 10, 100, MIN_ERROR, MAX_ERROR ) ).toBe( MAX_ERROR );
+ } );
+
+ test( 'returns minError with only a minimum set', () => {
+ expect( checkAmountRange( 1, 10, null, MIN_ERROR, MAX_ERROR ) ).toBe( MIN_ERROR );
+ expect( checkAmountRange( 10, 10, null, MIN_ERROR, MAX_ERROR ) ).toBeNull();
+ } );
+
+ test( 'returns maxError with only a maximum set', () => {
+ expect( checkAmountRange( 200, null, 100, MIN_ERROR, MAX_ERROR ) ).toBe( MAX_ERROR );
+ expect( checkAmountRange( 100, null, 100, MIN_ERROR, MAX_ERROR ) ).toBeNull();
+ } );
+} );
diff --git a/projects/plugins/jetpack/extensions/blocks/donations/utils.js b/projects/plugins/jetpack/extensions/blocks/donations/utils.js
index b129fbfbd281..5e7017a460d5 100644
--- a/projects/plugins/jetpack/extensions/blocks/donations/utils.js
+++ b/projects/plugins/jetpack/extensions/blocks/donations/utils.js
@@ -13,6 +13,26 @@ export function firstShownInterval( oneTimeShown, monthlyShown, annualShown ) {
return null;
}
+/**
+ * Returns an error message if amount falls outside the configured min/max range, or null if valid.
+ *
+ * @param {number} amount - The donation amount to check.
+ * @param {number|null} minAmount - Admin-configured minimum (null = no limit).
+ * @param {number|null} maxAmount - Admin-configured maximum (null = no limit).
+ * @param {string} minError - Pre-translated error string for below-minimum.
+ * @param {string} maxError - Pre-translated error string for above-maximum.
+ * @return {string|null} Error message, or null if amount is within range.
+ */
+export function checkAmountRange( amount, minAmount, maxAmount, minError, maxError ) {
+ if ( minAmount !== null && amount < minAmount ) {
+ return minError;
+ }
+ if ( maxAmount !== null && amount > maxAmount ) {
+ return maxError;
+ }
+ return null;
+}
+
/**
* Return the default texts defined in `donations.php` and injected client side by assigning them
* to the `Jetpack_DonationsBlock` attribute of the window object.
diff --git a/projects/plugins/jetpack/extensions/blocks/donations/view.js b/projects/plugins/jetpack/extensions/blocks/donations/view.js
index db8b1f1a15ca..7a5d7ca205db 100644
--- a/projects/plugins/jetpack/extensions/blocks/donations/view.js
+++ b/projects/plugins/jetpack/extensions/blocks/donations/view.js
@@ -3,6 +3,7 @@ import domReady from '@wordpress/dom-ready';
import { addQueryArgs, removeQueryArgs } from '@wordpress/url';
import { minimumTransactionAmountForCurrency, parseAmount } from '../../shared/currencies';
import { initializeMembershipButtons } from '../../shared/memberships';
+import { checkAmountRange as checkRange } from './utils';
import './view.scss';
@@ -18,6 +19,11 @@ class JetpackDonations {
this.amount = null;
this.isCustomAmount = false;
this.interval = block.dataset.defaultInterval || 'one-time';
+ this.minAmount = block.dataset.minAmount ? parseFloat( block.dataset.minAmount ) : null;
+ this.maxAmount = block.dataset.maxAmount ? parseFloat( block.dataset.maxAmount ) : null;
+ this.minError = block.dataset.minError || '';
+ this.maxError = block.dataset.maxError || '';
+ this.stripeMinError = block.dataset.stripeMinError || '';
// Initialize block.
this.initNavigation();
@@ -29,7 +35,7 @@ class JetpackDonations {
this.block.querySelector( '.donations__container' ).classList.add( 'loaded' );
}
- applyDefaultAmount( interval ) {
+ applyDefaultAmount( interval, isUserInitiated = false ) {
const amountClass = INTERVAL_TO_AMOUNT_CLASS[ interval ];
if ( ! amountClass ) {
return;
@@ -50,7 +56,15 @@ class JetpackDonations {
this.amount = tile.dataset.amount;
this.isCustomAmount = false;
this.updateUrl();
- this.toggleDonateButton( true );
+ const defaultRangeError = this.checkAmountRange( parseFloat( tile.dataset.amount ) );
+ if ( defaultRangeError ) {
+ if ( isUserInitiated ) {
+ this.showRangeError( defaultRangeError );
+ }
+ this.toggleDonateButton( false );
+ } else {
+ this.toggleDonateButton( true );
+ }
}
getNavItem( interval ) {
@@ -77,9 +91,15 @@ class JetpackDonations {
toggleDonateButton( enable ) {
const donateButton = this.getDonateButton();
- enable
- ? donateButton.classList.remove( 'is-disabled' )
- : donateButton.classList.add( 'is-disabled' );
+ if ( enable ) {
+ donateButton.classList.remove( 'is-disabled' );
+ donateButton.removeAttribute( 'aria-disabled' );
+ donateButton.removeAttribute( 'tabindex' );
+ } else {
+ donateButton.classList.add( 'is-disabled' );
+ donateButton.setAttribute( 'aria-disabled', 'true' );
+ donateButton.setAttribute( 'tabindex', '-1' );
+ }
}
updateUrl() {
@@ -115,9 +135,21 @@ class JetpackDonations {
if ( parsedAmount && parsedAmount >= minimumTransactionAmountForCurrency( currency ) ) {
wrapper.classList.remove( 'has-error' );
this.amount = parsedAmount;
- this.toggleDonateButton( true );
+ const customRangeError = this.checkAmountRange( parsedAmount );
+ if ( customRangeError ) {
+ this.showRangeError( customRangeError );
+ this.toggleDonateButton( false );
+ } else {
+ this.clearRangeError();
+ this.toggleDonateButton( true );
+ }
} else {
wrapper.classList.add( 'has-error' );
+ if ( parsedAmount && parsedAmount > 0 ) {
+ this.showRangeError( this.stripeMinError );
+ } else {
+ this.clearRangeError();
+ }
this.amount = null;
this.toggleDonateButton( false );
}
@@ -158,12 +190,13 @@ class JetpackDonations {
this.isCustomAmount = false;
this.resetSelectedAmount();
this.updateUrl();
+ this.clearRangeError();
// Disable donate button.
this.toggleDonateButton( false );
// Apply the new tab's default amount, if one is configured.
- this.applyDefaultAmount( newInterval );
+ this.applyDefaultAmount( newInterval, true );
};
navItems.forEach( navItem => {
@@ -240,16 +273,53 @@ class JetpackDonations {
}
this.updateUrl();
- // Enables the donate button.
- const donateButton = this.getDonateButton();
- donateButton.classList.remove( 'is-disabled' );
+ const rangeError = this.checkAmountRange( parseFloat( event.target.dataset.amount ) );
+ if ( rangeError ) {
+ this.showRangeError( rangeError );
+ this.toggleDonateButton( false );
+ } else {
+ this.clearRangeError();
+ this.toggleDonateButton( true );
+ }
} );
} );
// Disable all buttons on init since no amount has been chosen yet.
- this.block
- .querySelectorAll( '.donations__donate-button' )
- .forEach( button => button.classList.add( 'is-disabled' ) );
+ // Also attach a click guard before memberships.js adds its handler, so
+ // keyboard and AT users cannot activate a disabled button.
+ this.block.querySelectorAll( '.donations__donate-button' ).forEach( button => {
+ button.classList.add( 'is-disabled' );
+ button.setAttribute( 'aria-disabled', 'true' );
+ button.setAttribute( 'tabindex', '-1' );
+ button.addEventListener( 'click', event => {
+ if ( button.getAttribute( 'aria-disabled' ) === 'true' ) {
+ event.preventDefault();
+ event.stopImmediatePropagation();
+ }
+ } );
+ } );
+ }
+
+ checkAmountRange( amount ) {
+ return checkRange( amount, this.minAmount, this.maxAmount, this.minError, this.maxError );
+ }
+
+ showRangeError( message ) {
+ const el = this.block.querySelector( '.donations__range-error' );
+ if ( el ) {
+ el.setAttribute( 'role', 'alert' );
+ el.textContent = message;
+ el.classList.add( 'is-visible' );
+ }
+ }
+
+ clearRangeError() {
+ const el = this.block.querySelector( '.donations__range-error' );
+ if ( el ) {
+ el.removeAttribute( 'role' );
+ el.textContent = '';
+ el.classList.remove( 'is-visible' );
+ }
}
}
diff --git a/projects/plugins/jetpack/extensions/blocks/donations/view.scss b/projects/plugins/jetpack/extensions/blocks/donations/view.scss
index 4001e239ab7a..e537c1d3b7eb 100644
--- a/projects/plugins/jetpack/extensions/blocks/donations/view.scss
+++ b/projects/plugins/jetpack/extensions/blocks/donations/view.scss
@@ -107,6 +107,17 @@
}
}
+ .donations__range-error {
+ display: none;
+ color: gb.$alert-red;
+ font-size: 0.875em;
+ margin-block-end: 8px;
+
+ &.is-visible {
+ display: block;
+ }
+ }
+
.donations__donate-button.is-disabled {
pointer-events: none;
opacity: 0.2;
diff --git a/projects/plugins/jetpack/tests/php/extensions/blocks/Donations_Test.php b/projects/plugins/jetpack/tests/php/extensions/blocks/Donations_Test.php
index aa731bfb0e0d..1f8e15b9b0a7 100644
--- a/projects/plugins/jetpack/tests/php/extensions/blocks/Donations_Test.php
+++ b/projects/plugins/jetpack/tests/php/extensions/blocks/Donations_Test.php
@@ -9,15 +9,18 @@
use PHPUnit\Framework\Attributes\CoversFunction;
use PHPUnit\Framework\Attributes\DataProvider;
require_once JETPACK__PLUGIN_DIR . '/extensions/blocks/donations/donations.php';
+require_once JETPACK__PLUGIN_DIR . '/_inc/lib/class-jetpack-currencies.php';
/**
* Donations block tests.
*
* @covers ::Automattic\Jetpack\Extensions\Donations\build_custom_styles
* @covers ::Automattic\Jetpack\Extensions\Donations\sanitize_css_value
+ * @covers ::Automattic\Jetpack\Extensions\Donations\build_security_data_attrs
*/
#[CoversFunction( 'Automattic\\Jetpack\\Extensions\\Donations\\sanitize_css_value' )]
#[CoversFunction( 'Automattic\\Jetpack\\Extensions\\Donations\\build_custom_styles' )]
+#[CoversFunction( 'Automattic\\Jetpack\\Extensions\\Donations\\build_security_data_attrs' )]
class Donations_Test extends \WP_UnitTestCase {
use \Automattic\Jetpack\PHPUnit\WP_UnitTestCase_Fix;
@@ -340,4 +343,88 @@ public function test_build_custom_styles_drops_unknown_alignment() {
$css = Donations\build_custom_styles( array( 'buttonAlignment' => 'wat' ), '.jp-donations-1' );
$this->assertStringNotContainsString( 'donate-button-wrapper', $css );
}
+
+ /**
+ * No min/max set: only data-stripe-min-error is present.
+ */
+ public function test_build_security_data_attrs_no_limits() {
+ $attrs = Donations\build_security_data_attrs( array(), 'USD' );
+
+ $this->assertArrayNotHasKey( 'data-min-amount', $attrs );
+ $this->assertArrayNotHasKey( 'data-min-error', $attrs );
+ $this->assertArrayNotHasKey( 'data-max-amount', $attrs );
+ $this->assertArrayNotHasKey( 'data-max-error', $attrs );
+ $this->assertArrayHasKey( 'data-stripe-min-error', $attrs );
+ $this->assertStringContainsString( '$0.50', $attrs['data-stripe-min-error'] );
+ }
+
+ /**
+ * Minimum set: data-min-amount and data-min-error are present with correct values.
+ */
+ public function test_build_security_data_attrs_with_minimum() {
+ $attrs = Donations\build_security_data_attrs( array( 'minimumAmount' => 10 ), 'USD' );
+
+ $this->assertSame( 10.0, $attrs['data-min-amount'] );
+ $this->assertStringContainsString( '$10.00', $attrs['data-min-error'] );
+ $this->assertArrayNotHasKey( 'data-max-amount', $attrs );
+ }
+
+ /**
+ * Maximum set: data-max-amount and data-max-error are present with correct values.
+ */
+ public function test_build_security_data_attrs_with_maximum() {
+ $attrs = Donations\build_security_data_attrs( array( 'maximumAmount' => 500 ), 'USD' );
+
+ $this->assertSame( 500.0, $attrs['data-max-amount'] );
+ $this->assertStringContainsString( '$500.00', $attrs['data-max-error'] );
+ $this->assertArrayNotHasKey( 'data-min-amount', $attrs );
+ }
+
+ /**
+ * Stripe floor message reflects the currency — GBP minimum is £0.30, not $0.50.
+ */
+ public function test_build_security_data_attrs_stripe_min_is_currency_aware() {
+ $usd_attrs = Donations\build_security_data_attrs( array(), 'USD' );
+ $gbp_attrs = Donations\build_security_data_attrs( array(), 'GBP' );
+
+ $this->assertStringContainsString( '$0.50', $usd_attrs['data-stripe-min-error'] );
+ // GBP symbol is stored as an HTML entity in CURRENCIES, so format_price returns '£'.
+ $this->assertStringContainsString( '£0.30', $gbp_attrs['data-stripe-min-error'] );
+ }
+
+ /**
+ * Both min and max set: all four data attributes are present with correct values.
+ */
+ public function test_build_security_data_attrs_with_both_limits() {
+ $attrs = Donations\build_security_data_attrs(
+ array(
+ 'minimumAmount' => 5,
+ 'maximumAmount' => 250,
+ ),
+ 'USD'
+ );
+
+ $this->assertSame( 5.0, $attrs['data-min-amount'] );
+ $this->assertStringContainsString( '$5.00', $attrs['data-min-error'] );
+ $this->assertSame( 250.0, $attrs['data-max-amount'] );
+ $this->assertStringContainsString( '$250.00', $attrs['data-max-error'] );
+ $this->assertArrayHasKey( 'data-stripe-min-error', $attrs );
+ }
+
+ /**
+ * Inverted config (max < min): both data attributes are still emitted.
+ * The editor shows a warning; the PHP layer does not silently discard either value.
+ */
+ public function test_build_security_data_attrs_inverted_limits_both_emitted() {
+ $attrs = Donations\build_security_data_attrs(
+ array(
+ 'minimumAmount' => 100,
+ 'maximumAmount' => 10,
+ ),
+ 'USD'
+ );
+
+ $this->assertSame( 100.0, $attrs['data-min-amount'] );
+ $this->assertSame( 10.0, $attrs['data-max-amount'] );
+ }
}