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

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

275 lines 10.8 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 $donor_comment = sanitize_textarea_field( wp_unslash( $_POST['donor_comment'] ?? '' ) );
139
140 // Get form_id and block_id for amount validation.
141 $form_id = isset( $_POST['form_id'] ) ? absint( $_POST['form_id'] ) : 0;
142 $is_anonymous = Payment_Helper::get_submitted_is_anonymous( $form_id );
143 // Derive the donor phone from the validated mapped field, not a separate
144 // unvalidated $_POST['donor_phone'] (see Payment_Helper::get_mapped_donor_phone).
145 $donor_phone = Payment_Helper::get_mapped_donor_phone( $form_id );
146 $block_id = isset( $_POST['block_id'] ) ? sanitize_text_field( wp_unslash( $_POST['block_id'] ) ) : '';
147
148 // Validate required fields.
149 if ( $amount <= 0 ) {
150 wp_send_json_error( __( 'Invalid donation amount', 'suredonation' ) );
151 }
152
153 // Require form_id and block_id for amount validation — reject if missing to prevent bypass.
154 if ( empty( $form_id ) || empty( $block_id ) ) {
155 wp_send_json_error( __( 'Invalid form configuration.', 'suredonation' ) );
156 }
157
158 // Validate field values + amount against block configuration. Pass the
159 // offline gateway so the Stripe-only minimum floor is not applied here.
160 $currency = Payment_Helper::get_currency();
161 $validation_result = Payment_Helper::validate_submission( Payment_Helper::get_submitted_fields(), $amount, $currency, $form_id, $block_id, 'offline', 'one-time' );
162 if ( ! $validation_result['valid'] ) {
163 wp_send_json_error( esc_html( $validation_result['message'] ) );
164 }
165
166 // Name and email are required whether or not the donation is anonymous —
167 // the flag only masks the name on public surfaces, so there still has to
168 // be a real name to mask (matches the gateway handlers, which validate
169 // these through validate_submission() regardless of the flag).
170 if ( empty( $donor_name ) ) {
171 wp_send_json_error( __( 'Donor name is required', 'suredonation' ) );
172 }
173 if ( empty( $donor_email ) || ! is_email( $donor_email ) ) {
174 wp_send_json_error( __( 'Valid email address is required', 'suredonation' ) );
175 }
176
177 // Server-side fee calculation — ignore client-supplied base_amount to prevent manipulation.
178 $base_amount = $amount;
179 $fees_covered = 0;
180
181 if ( $cover_fees && $base_amount > 0 ) {
182 $fee_config = Payment_Helper::get_cover_fees_config( $form_id, 'offline' );
183
184 if ( ! $fee_config['enabled'] ) {
185 $cover_fees = false;
186 }
187
188 if ( $cover_fees ) {
189 $fees_covered = Payment_Helper::calculate_fee( $base_amount, $fee_config['fee_percentage'], $fee_config['fee_fixed'] );
190 } else {
191 $fees_covered = 0;
192 }
193 }
194
195 // Get or create donor. The email is validated as non-empty above, so
196 // there is no guard here — anonymous or not, this path always has one.
197 $donor_id = Donors::get_or_create( $donor_email, $donor_name, $donor_phone );
198
199 // Get payment mode.
200 $payment_mode = 'live';
201 if ( class_exists( 'SureDonation\Inc\Payments\Payment_Helper' ) ) {
202 $payment_mode = Payment_Helper::get_payment_mode();
203 }
204
205 // Create donation in database.
206 $donation_id = Donations::add(
207 [
208 'campaign_id' => $campaign_id,
209 'donor_id' => $donor_id ? $donor_id : 0,
210 'amount' => number_format( $base_amount, 2, '.', '' ),
211 'fees_covered' => number_format( $fees_covered, 2, '.', '' ),
212 'currency' => Payment_Helper::get_currency(),
213 'gateway' => 'manual',
214 'payment_status' => 'pending',
215 'payment_mode' => $payment_mode,
216 'donor_name' => $donor_name,
217 'donor_email' => $donor_email,
218 'donor_phone' => $donor_phone,
219 'is_anonymous' => $is_anonymous ? 1 : 0,
220 'donation_type' => 'one-time',
221 'donor_comment' => $donor_comment,
222 'form_id' => $form_id,
223 'ip_address' => Helper::get_client_ip(),
224 'user_agent' => isset( $_SERVER['HTTP_USER_AGENT'] ) ? sanitize_text_field( wp_unslash( $_SERVER['HTTP_USER_AGENT'] ) ) : '',
225 'referer_url' => isset( $_SERVER['HTTP_REFERER'] ) ? esc_url_raw( wp_unslash( $_SERVER['HTTP_REFERER'] ) ) : '',
226 ]
227 );
228
229 if ( ! $donation_id ) {
230 wp_send_json_error( __( 'Failed to create donation', 'suredonation' ) );
231 }
232
233 // Persist the submitted field values for the entry record.
234 Donations::set_submitted_fields( $donation_id, Payment_Helper::get_submitted_field_data() );
235
236 // Note: Donation status will be updated by payment gateway webhooks or manual confirmation.
237
238 // This donation is created as pending/manual, so send the "processing"
239 // (donation received) email rather than the completed-confirmation
240 // email. The confirmation email is reserved for when payment is
241 // actually confirmed, matching the gateway flows.
242 $donation_data = [
243 'id' => $donation_id,
244 'donor_name' => $donor_name,
245 'donor_email' => $donor_email,
246 'amount' => $base_amount,
247 'fees_covered' => $fees_covered,
248 'currency' => Payment_Helper::get_currency(),
249 'gateway' => 'manual',
250 // One-time regardless of the block's configured type, and intentionally
251 // unguarded: this handler has no remaining caller in src/, writes a
252 // record rather than moving money, and gating it on payment type would
253 // reject manual entries on recurring forms. Whether it should still be
254 // registered at all is the better question, tracked separately.
255 'donation_type' => 'one-time',
256 ];
257
258 Email_Handler::send_donation_processing( $donation_id, $campaign_id, $donation_data, $form_id );
259
260 // Build the confirmation/thank-you HTML from the form's confirmation message.
261 $confirmation_html = Helper::render_confirmation_message( $donation_id );
262 if ( '' === $confirmation_html ) {
263 $confirmation_html = esc_html__( 'Your generous contribution will make a real difference. A confirmation email has been sent to you.', 'suredonation' );
264 }
265
266 // Send success response.
267 wp_send_json_success(
268 [
269 'donation_id' => $donation_id,
270 'message' => $confirmation_html,
271 ]
272 );
273 }
274 }
275