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'] ); + } }