PluginProbe
SureDonation – Donation Forms, Fundraising Campaigns & Donor Management / 1.6.0
SureDonation – Donation Forms, Fundraising Campaigns & Donor Management v1.6.0
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
suredonation / inc / ajax / donation-handler.php

donation-handler.php in SureDonation – Donation Forms, Fundraising Campaigns & Donor Management 1.6.0, at inc/ajax/donation-handler.php

279 lines 11.2 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2 /**
3 * AJAX Donation Handler
4 *
5 * @package SureDonation
6 */
7
8 namespace SureDonation\Inc\Ajax;
9
10 use SureDonation\Inc\Database\Tables\Donations;
11 use SureDonation\Inc\Database\Tables\Donors;
12 use SureDonation\Inc\Emails\Email_Handler;
13 use SureDonation\Inc\Helper;
14 use SureDonation\Inc\Payments\Payment_Helper;
15 use SureDonation\Inc\Traits\Get_Instance;
16
17 // Exit if accessed directly.
18 if ( ! defined( 'ABSPATH' ) ) {
19 exit;
20 }
21
22 /**
23 * Donation_Handler class.
24 *
25 * @since 0.0.1
26 */
27 class Donation_Handler {
28 use Get_Instance;
29
30 /**
31 * Constructor.
32 *
33 * @since 0.0.1
34 */
35 public function __construct() {
36 add_action( 'wp_ajax_suredonation_submit_donation', [ $this, 'handle_donation_submission' ] );
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' ] );
42 }
43
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 /**
78 * Handle donation form submission.
79 *
80 * @return void
81 * @since 0.0.1
82 */
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
89 // First check if nonce exists before accessing any other POST data.
90 if ( ! isset( $_POST['suredonation_nonce'] ) ) {
91 wp_send_json_error( __( 'Security check failed', 'suredonation' ) );
92 }
93
94 // Sanitize nonce value.
95 $nonce = sanitize_text_field( wp_unslash( $_POST['suredonation_nonce'] ) );
96
97 // Now get values needed to determine nonce action.
98 $is_standalone = isset( $_POST['is_standalone'] ) && '1' === $_POST['is_standalone'];
99 $campaign_id = isset( $_POST['campaign_id'] ) ? absint( $_POST['campaign_id'] ) : 0;
100
101 // Standalone forms must not have a campaign — prevent bypass of campaign validation.
102 if ( $is_standalone ) {
103 $campaign_id = 0;
104 }
105
106 // Verify nonce - different nonce for standalone vs campaign-linked forms.
107 $nonce_action = Helper::get_donation_nonce_action( $campaign_id );
108
109 if ( ! wp_verify_nonce( $nonce, $nonce_action ) ) {
110 wp_send_json_error( __( 'Security check failed', 'suredonation' ) );
111 }
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
118 // Validate campaign only if not standalone.
119 $campaign = null;
120 if ( ! $is_standalone ) {
121 if ( ! $campaign_id ) {
122 wp_send_json_error( __( 'Invalid campaign', 'suredonation' ) );
123 }
124
125 $campaign = get_post( $campaign_id );
126 if ( ! $campaign || SUREDONATION_POST_TYPE !== $campaign->post_type ) {
127 wp_send_json_error( __( 'Invalid campaign', 'suredonation' ) );
128 }
129 }
130
131 // Get form data.
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'] ?? '' ) );
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
151 // Validate required fields.
152 if ( $amount <= 0 ) {
153 wp_send_json_error( __( 'Invalid donation amount', 'suredonation' ) );
154 }
155
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' ) );
159 }
160
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 }
168
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' ) );
175 }
176 if ( empty( $donor_email ) || ! is_email( $donor_email ) ) {
177 wp_send_json_error( __( 'Valid email address is required', 'suredonation' ) );
178 }
179
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 }
196 }
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
202 // Get payment mode.
203 $payment_mode = 'live';
204 if ( class_exists( 'SureDonation\Inc\Payments\Payment_Helper' ) ) {
205 $payment_mode = Payment_Helper::get_payment_mode();
206 }
207
208 // Create donation in database.
209 $donation_id = Donations::add(
210 [
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'] ) ) : '',
230 ]
231 );
232
233 if ( ! $donation_id ) {
234 wp_send_json_error( __( 'Failed to create donation', 'suredonation' ) );
235 }
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
240 // Note: Donation status will be updated by payment gateway webhooks or manual confirmation.
241
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.
246 $donation_data = [
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',
260 ];
261
262 Email_Handler::send_donation_processing( $donation_id, $campaign_id, $donation_data, $form_id );
263
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' );
268 }
269
270 // Send success response.
271 wp_send_json_success(
272 [
273 'donation_id' => $donation_id,
274 'message' => $confirmation_html,
275 ]
276 );
277 }
278 }
279