PluginProbe
SureDonation – Donation Forms, Fundraising Campaigns & Donor Management / 1.6.1
SureDonation – Donation Forms, Fundraising Campaigns & Donor Management v1.6.1
1.6.1 1.6.0 1.5.1 1.5.0 1.4.0 1.3.0 trunk 0.0.1 1.0.0 1.1.0 1.1.1 1.1.2 1.2.0
← All changes | inc/ajax/donation-handler.php +156 -62 0.0.1 → 1.6.1 View file →
@@ -34,11 +34,48 @@
34 34 */
35 35 public function __construct() {
36 36 add_action( 'wp_ajax_suredonation_submit_donation', [ $this, 'handle_donation_submission' ] );
37 37 add_action( 'wp_ajax_nopriv_suredonation_submit_donation', [ $this, 'handle_donation_submission' ] );
38 +
39 + // Runtime gateway configuration, read by the form script when it initialises.
40 + add_action( 'wp_ajax_suredonation_gateway_config', [ $this, 'get_gateway_config' ] );
41 + add_action( 'wp_ajax_nopriv_suredonation_gateway_config', [ $this, 'get_gateway_config' ] );
38 42 }
39 43
40 44 /**
45 + * Serve the gateway configuration for a donation form.
46 + *
47 + * Public read, fetched by the form script when it initialises so the Stripe
48 + * key, PayPal SDK URL, payment mode and currency reflect the settings as
49 + * they are now — not as they were when a page cache stored the form. It
50 + * goes through admin-ajax, which page caches leave alone by default and
51 + * which keeps working on sites that restrict the REST API for visitors.
52 + *
53 + * @return void
54 + * @since 1.5.1
55 + */
56 + public function get_gateway_config() {
57 + // Throttle abuse as every other public endpoint does. The ceiling is
58 + // far above the default because this fires once per form page view,
59 + // not per donor action, and many visitors can legitimately share one
60 + // address (an office or campus NAT). When it trips, the scripts fall
61 + // back to the rendered configuration rather than failing.
62 + if ( ! Helper::check_rate_limit( 'gateway_config', 120 ) ) {
63 + wp_send_json_error( [ 'message' => __( 'Too many requests. Please wait a moment and try again.', 'suredonation' ) ], 429 );
64 + }
65 +
66 + // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- Public read of non-secret data; nothing changes state, and a nonce would be cached with the page it is meant to protect.
67 + $form_id = isset( $_GET['form_id'] ) ? absint( $_GET['form_id'] ) : 0;
68 +
69 + // Freshness is the whole point of this response. admin-ajax already
70 + // sends these, but an edge cache with a blanket rule would not care,
71 + // so the guarantee is made explicit rather than inherited.
72 + nocache_headers();
73 +
74 + wp_send_json_success( Payment_Helper::get_frontend_gateway_config( $form_id ) );
75 + }
76 +
77 + /**
41 78 * Handle donation form submission.
42 79 *
43 80 * @return void
44 81 * @since 0.0.1
@@ -43,8 +80,13 @@
43 80 * @return void
44 81 * @since 0.0.1
45 82 */
46 83 public function handle_donation_submission() {
84 + // Throttle abuse on this public endpoint before doing any work.
85 + if ( ! Helper::check_rate_limit( 'submit_donation' ) ) {
86 + wp_send_json_error( __( 'Too many requests. Please wait a moment and try again.', 'suredonation' ), 429 );
87 + }
88 +
47 89 // First check if nonce exists before accessing any other POST data.
48 90 if ( ! isset( $_POST['suredonation_nonce'] ) ) {
49 91 wp_send_json_error( __( 'Security check failed', 'suredonation' ) );
50 92 }
@@ -53,17 +95,27 @@
53 95 $nonce = sanitize_text_field( wp_unslash( $_POST['suredonation_nonce'] ) );
54 96
55 97 // Now get values needed to determine nonce action.
56 98 $is_standalone = isset( $_POST['is_standalone'] ) && '1' === $_POST['is_standalone'];
57 - $campaign_id = isset( $_POST['campaign_id'] ) ? intval( $_POST['campaign_id'] ) : 0;
99 + $campaign_id = isset( $_POST['campaign_id'] ) ? absint( $_POST['campaign_id'] ) : 0;
58 100
101 + // Standalone forms must not have a campaign — prevent bypass of campaign validation.
102 + if ( $is_standalone ) {
103 + $campaign_id = 0;
104 + }
105 +
59 106 // Verify nonce - different nonce for standalone vs campaign-linked forms.
60 - $nonce_action = $is_standalone ? 'suredonation_donation_standalone' : 'suredonation_donation_' . $campaign_id;
107 + $nonce_action = Helper::get_donation_nonce_action( $campaign_id );
61 108
62 109 if ( ! wp_verify_nonce( $nonce, $nonce_action ) ) {
63 110 wp_send_json_error( __( 'Security check failed', 'suredonation' ) );
64 111 }
65 112
113 + // Reject bot submissions caught by the honeypot before processing.
114 + if ( Helper::is_honeypot_spam() ) {
115 + wp_send_json_error( __( 'Your submission was flagged as spam. Please try again.', 'suredonation' ) );
116 + }
117 +
66 118 // Validate campaign only if not standalone.
67 119 $campaign = null;
68 120 if ( ! $is_standalone ) {
69 121 if ( ! $campaign_id ) {
@@ -76,48 +128,78 @@
76 128 }
77 129 }
78 130
79 131 // Get form data.
80 - $amount = isset( $_POST['amount'] ) ? floatval( $_POST['amount'] ) : 0;
81 - $cover_fees = isset( $_POST['cover_fees'] ) ? true : false;
82 - $is_anonymous = isset( $_POST['is_anonymous'] ) ? true : false;
83 - $donor_name = $is_anonymous ? __( 'Anonymous', 'suredonation' ) : sanitize_text_field( wp_unslash( $_POST['donor_name'] ?? '' ) );
84 - $donor_email = sanitize_email( wp_unslash( $_POST['donor_email'] ?? '' ) );
85 - $donor_phone = sanitize_text_field( wp_unslash( $_POST['donor_phone'] ?? '' ) );
86 - $donor_comment = sanitize_textarea_field( wp_unslash( $_POST['donor_comment'] ?? '' ) );
132 + $amount = isset( $_POST['amount'] ) ? floatval( $_POST['amount'] ) : 0;
133 + $cover_fees = isset( $_POST['cover_fees'] ) && 'true' === $_POST['cover_fees'];
134 + // The anonymous flag is display-only: the donor's real name is stored as
135 + // usual below and only public surfaces mask it.
136 + $donor_name = sanitize_text_field( wp_unslash( $_POST['donor_name'] ?? '' ) );
137 + $donor_email = sanitize_email( wp_unslash( $_POST['donor_email'] ?? '' ) );
87 138
139 + // Get form_id and block_id for amount validation.
140 + $form_id = isset( $_POST['form_id'] ) ? absint( $_POST['form_id'] ) : 0;
141 + $is_anonymous = Payment_Helper::get_submitted_is_anonymous( $form_id );
142 + // Derive the donor phone from the validated mapped field, not a separate
143 + // unvalidated $_POST['donor_phone'] (see Payment_Helper::get_mapped_donor_phone).
144 + $donor_phone = Payment_Helper::get_mapped_donor_phone( $form_id );
145 + // Likewise derive the comment from the form's Donor Comment field rather
146 + // than an unvalidated $_POST['donor_comment'] (see
147 + // Payment_Helper::get_mapped_donor_comment).
148 + $donor_comment = Payment_Helper::get_mapped_donor_comment( $form_id );
149 + $block_id = isset( $_POST['block_id'] ) ? sanitize_text_field( wp_unslash( $_POST['block_id'] ) ) : '';
150 +
88 151 // Validate required fields.
89 152 if ( $amount <= 0 ) {
90 153 wp_send_json_error( __( 'Invalid donation amount', 'suredonation' ) );
91 154 }
92 155
93 - if ( ! $is_anonymous ) {
94 - if ( empty( $donor_name ) ) {
95 - wp_send_json_error( __( 'Donor name is required', 'suredonation' ) );
96 - }
97 - if ( empty( $donor_email ) || ! is_email( $donor_email ) ) {
98 - wp_send_json_error( __( 'Valid email address is required', 'suredonation' ) );
99 - }
156 + // Require form_id and block_id for amount validation — reject if missing to prevent bypass.
157 + if ( empty( $form_id ) || empty( $block_id ) ) {
158 + wp_send_json_error( __( 'Invalid form configuration.', 'suredonation' ) );
100 159 }
101 160
102 - // Calculate fee and amounts.
103 - $fee_percentage = 0.029; // 2.9%
104 - $fee_fixed = 0.30;
105 - $base_amount = $amount;
106 - $fees_covered = 0;
161 + // Validate field values + amount against block configuration. Pass the
162 + // offline gateway so the Stripe-only minimum floor is not applied here.
163 + $currency = Payment_Helper::get_currency();
164 + $validation_result = Payment_Helper::validate_submission( Payment_Helper::get_submitted_fields(), $amount, $currency, $form_id, $block_id, 'offline', 'one-time' );
165 + if ( ! $validation_result['valid'] ) {
166 + wp_send_json_error( esc_html( $validation_result['message'] ) );
167 + }
107 168
108 - if ( $cover_fees ) {
109 - // Calculate the base amount from total (reverse calculation).
110 - $base_amount = ( $amount - $fee_fixed ) / ( 1 + $fee_percentage );
111 - $fees_covered = $amount - $base_amount;
169 + // Name and email are required whether or not the donation is anonymous —
170 + // the flag only masks the name on public surfaces, so there still has to
171 + // be a real name to mask (matches the gateway handlers, which validate
172 + // these through validate_submission() regardless of the flag).
173 + if ( empty( $donor_name ) ) {
174 + wp_send_json_error( __( 'Donor name is required', 'suredonation' ) );
112 175 }
176 + if ( empty( $donor_email ) || ! is_email( $donor_email ) ) {
177 + wp_send_json_error( __( 'Valid email address is required', 'suredonation' ) );
178 + }
113 179
114 - // Get or create donor.
115 - $donor_id = 0;
116 - if ( ! empty( $donor_email ) ) {
117 - $donor_id = Donors::get_or_create( $donor_email, $donor_name, $donor_phone );
180 + // Server-side fee calculation — ignore client-supplied base_amount to prevent manipulation.
181 + $base_amount = $amount;
182 + $fees_covered = 0;
183 +
184 + if ( $cover_fees && $base_amount > 0 ) {
185 + $fee_config = Payment_Helper::get_cover_fees_config( $form_id, 'offline' );
186 +
187 + if ( ! $fee_config['enabled'] ) {
188 + $cover_fees = false;
189 + }
190 +
191 + if ( $cover_fees ) {
192 + $fees_covered = Payment_Helper::calculate_fee( $base_amount, $fee_config['fee_percentage'], $fee_config['fee_fixed'] );
193 + } else {
194 + $fees_covered = 0;
195 + }
118 196 }
119 197
198 + // Get or create donor. The email is validated as non-empty above, so
199 + // there is no guard here — anonymous or not, this path always has one.
200 + $donor_id = Donors::get_or_create( $donor_email, $donor_name, $donor_phone );
201 +
120 202 // Get payment mode.
121 203 $payment_mode = 'live';
122 204 if ( class_exists( 'SureDonation\Inc\Payments\Payment_Helper' ) ) {
123 205 $payment_mode = Payment_Helper::get_payment_mode();
@@ -125,25 +207,27 @@
125 207
126 208 // Create donation in database.
127 209 $donation_id = Donations::add(
128 210 [
129 - 'campaign_id' => $campaign_id,
130 - 'donor_id' => $donor_id ? $donor_id : 0,
131 - 'amount' => number_format( $base_amount, 2, '.', '' ),
132 - 'fees_covered' => number_format( $fees_covered, 2, '.', '' ),
133 - 'currency' => Payment_Helper::get_currency(),
134 - 'gateway' => 'manual',
135 - 'payment_status' => 'pending',
136 - 'payment_mode' => $payment_mode,
137 - 'donor_name' => $donor_name,
138 - 'donor_email' => $donor_email,
139 - 'donor_phone' => $donor_phone,
140 - 'is_anonymous' => $is_anonymous ? 1 : 0,
141 - 'donation_type' => 'one-time',
142 - 'donor_comment' => $donor_comment,
143 - 'ip_address' => Helper::get_client_ip(),
144 - 'user_agent' => isset( $_SERVER['HTTP_USER_AGENT'] ) ? sanitize_text_field( wp_unslash( $_SERVER['HTTP_USER_AGENT'] ) ) : '',
145 - 'referer_url' => isset( $_SERVER['HTTP_REFERER'] ) ? esc_url_raw( wp_unslash( $_SERVER['HTTP_REFERER'] ) ) : '',
211 + 'campaign_id' => $campaign_id,
212 + 'donor_id' => $donor_id ? $donor_id : 0,
213 + 'amount' => number_format( $base_amount, 2, '.', '' ),
214 + 'fees_covered' => number_format( $fees_covered, 2, '.', '' ),
215 + 'currency' => Payment_Helper::get_currency(),
216 + 'gateway' => 'manual',
217 + 'payment_status' => 'pending',
218 + 'payment_mode' => $payment_mode,
219 + 'donor_name' => $donor_name,
220 + 'donor_email' => $donor_email,
221 + 'donor_phone' => $donor_phone,
222 + 'is_anonymous' => $is_anonymous ? 1 : 0,
223 + 'donation_type' => 'one-time',
224 + 'donor_comment' => $donor_comment,
225 + 'donor_comment_status' => Donations::initial_comment_status( $donor_comment ),
226 + 'form_id' => $form_id,
227 + 'ip_address' => Helper::get_client_ip(),
228 + 'user_agent' => isset( $_SERVER['HTTP_USER_AGENT'] ) ? sanitize_text_field( wp_unslash( $_SERVER['HTTP_USER_AGENT'] ) ) : '',
229 + 'referer_url' => isset( $_SERVER['HTTP_REFERER'] ) ? esc_url_raw( wp_unslash( $_SERVER['HTTP_REFERER'] ) ) : '',
146 230 ]
147 231 );
148 232
149 233 if ( ! $donation_id ) {
@@ -149,36 +233,46 @@
149 233 if ( ! $donation_id ) {
150 234 wp_send_json_error( __( 'Failed to create donation', 'suredonation' ) );
151 235 }
152 236
237 + // Persist the submitted field values for the entry record.
238 + Donations::set_submitted_fields( $donation_id, Payment_Helper::get_submitted_field_data() );
239 +
153 240 // Note: Donation status will be updated by payment gateway webhooks or manual confirmation.
154 241
155 - // Send donation confirmation email.
242 + // This donation is created as pending/manual, so send the "processing"
243 + // (donation received) email rather than the completed-confirmation
244 + // email. The confirmation email is reserved for when payment is
245 + // actually confirmed, matching the gateway flows.
156 246 $donation_data = [
157 - 'id' => $donation_id,
158 - 'donor_name' => $donor_name,
159 - 'donor_email' => $donor_email,
160 - 'amount' => $base_amount,
161 - 'fees_covered' => $fees_covered,
162 - 'currency' => Payment_Helper::get_currency(),
247 + 'id' => $donation_id,
248 + 'donor_name' => $donor_name,
249 + 'donor_email' => $donor_email,
250 + 'amount' => $base_amount,
251 + 'fees_covered' => $fees_covered,
252 + 'currency' => Payment_Helper::get_currency(),
253 + 'gateway' => 'manual',
254 + // One-time regardless of the block's configured type, and intentionally
255 + // unguarded: this handler has no remaining caller in src/, writes a
256 + // record rather than moving money, and gating it on payment type would
257 + // reject manual entries on recurring forms. Whether it should still be
258 + // registered at all is the better question, tracked separately.
259 + 'donation_type' => 'one-time',
163 260 ];
164 261
165 - Email_Handler::send_donation_confirmation( $donation_id, $campaign_id, $donation_data );
262 + Email_Handler::send_donation_processing( $donation_id, $campaign_id, $donation_data, $form_id );
166 263
167 - // Get thank you message.
168 - $thank_you_message = '';
169 - if ( ! $is_standalone && $campaign_id ) {
170 - $thank_you_message = Helper::get_campaign_meta_value( $campaign_id, 'thank_you_message', '' );
264 + // Build the confirmation/thank-you HTML from the form's confirmation message.
265 + $confirmation_html = Helper::render_confirmation_message( $donation_id );
266 + if ( '' === $confirmation_html ) {
267 + $confirmation_html = esc_html__( 'Your generous contribution will make a real difference. A confirmation email has been sent to you.', 'suredonation' );
171 268 }
172 - if ( empty( $thank_you_message ) ) {
173 - $thank_you_message = esc_html__( 'Your generous contribution will make a real difference. A confirmation email has been sent to you.', 'suredonation' );
174 - }
175 269
176 270 // Send success response.
177 271 wp_send_json_success(
178 272 [
179 273 'donation_id' => $donation_id,
180 - 'message' => wp_kses_post( Helper::get_string_value( $thank_you_message ) ),
274 + 'message' => $confirmation_html,
181 275 ]
182 276 );
183 277 }
184 278 }