PluginProbe ʕ •ᴥ•ʔ
SureForms – Contact Form Builder, AI Forms, Payment Form, Survey & Quiz / 2.12.1
SureForms – Contact Form Builder, AI Forms, Payment Form, Survey & Quiz v2.12.1
2.12.2 2.12.1 2.12.0 2.11.1 2.11.0 2.10.1 2.10.0 2.9.1 2.9.0 2.8.2 2.8.1 2.7.0 2.7.1 2.8.0 trunk 0.0.10 0.0.11 0.0.12 0.0.13 0.0.2 0.0.3 0.0.4 0.0.5 0.0.6 0.0.7 0.0.8 0.0.9 1.0.0 1.0.1 1.0.2 1.0.3 1.0.4 1.0.5 1.0.6 1.0.7 1.1.0 1.1.1 1.1.2 1.10.0 1.10.1 1.11.0 1.12.0 1.12.1 1.12.2 1.12.3 1.13.0 1.13.1 1.13.2 1.2.0 1.2.1 1.2.2 1.2.3 1.2.4 1.2.5 1.3.0 1.3.1 1.3.2 1.4.0 1.4.1 1.4.2 1.4.3 1.4.4 1.4.5 1.5.0 1.5.1 1.6.0 1.6.1 1.6.2 1.6.3 1.6.4 1.6.5 1.7.0 1.7.1 1.7.2 1.7.3 1.7.4 1.8.0 1.9.0 1.9.1 2.0.0 2.0.1 2.0.2 2.1.0 2.1.1 2.2.0 2.2.1 2.2.2 2.3.0 2.4.0 2.5.0 2.5.2 2.6.0
sureforms / inc / payments / admin / admin-handler.php
sureforms / inc / payments / admin Last commit date
admin-handler.php 4 weeks ago
admin-handler.php
1512 lines
1 <?php
2 /**
3 * Admin Payment Operations Handler Class.
4 *
5 * @package sureforms.
6 */
7
8 namespace SRFM\Inc\Payments\Admin;
9
10 use SRFM\Inc\Database\Tables\Payments;
11 use SRFM\Inc\Helper;
12 use SRFM\Inc\Payments\Payment_Helper;
13 use SRFM\Inc\Traits\Get_Instance;
14
15 if ( ! defined( 'ABSPATH' ) ) {
16 exit; // Exit if accessed directly.
17 }
18
19 /**
20 * Admin Payment Operations handler class.
21 *
22 * @since 2.0.0
23 */
24 class Admin_Handler {
25 use Get_Instance;
26
27 /**
28 * Class constructor.
29 *
30 * @since 2.0.0
31 * @return void
32 */
33 public function __construct() {
34 add_action( 'admin_enqueue_scripts', [ $this, 'enqueue_scripts' ] );
35 add_action( 'wp_ajax_srfm_fetch_payments_transactions', [ $this, 'fetch_payments' ] );
36 add_action( 'wp_ajax_srfm_fetch_single_payment', [ $this, 'fetch_single_payment' ] );
37 add_action( 'wp_ajax_srfm_fetch_subscription', [ $this, 'fetch_subscription' ] );
38 add_action( 'wp_ajax_srfm_fetch_forms_list', [ $this, 'fetch_forms_list' ] );
39 add_action( 'wp_ajax_srfm_add_payment_note', [ $this, 'ajax_add_note' ] );
40 add_action( 'wp_ajax_srfm_delete_payment_note', [ $this, 'ajax_delete_note' ] );
41 add_action( 'wp_ajax_srfm_delete_payment_log', [ $this, 'ajax_delete_log' ] );
42 add_action( 'wp_ajax_srfm_bulk_delete_payments', [ $this, 'ajax_bulk_delete_payments' ] );
43 add_action( 'wp_ajax_srfm_refund_payment', [ $this, 'ajax_refund_payment' ] );
44 }
45
46 /**
47 * Enqueue Admin Scripts for Payment Operations.
48 *
49 * @since 2.0.0
50 * @return void
51 */
52 public function enqueue_scripts() {
53 $current_screen = get_current_screen();
54
55 /**
56 * List of the handles in which we need to add translation compatibility.
57 */
58 $script_translations_handlers = [];
59
60 // Check if we're on payment related pages.
61 if ( isset( $current_screen->id ) &&
62 ( strpos( $current_screen->id, 'sureforms_payments' ) !== false || strpos( $current_screen->id, 'sureforms_payments_react' ) !== false ) ) {
63 // Enqueue payment specific scripts.
64 wp_enqueue_script( SRFM_SLUG . '-payments', SRFM_URL . 'assets/build/payments.js', [], SRFM_VER, true );
65 wp_enqueue_style( SRFM_SLUG . '-payments', SRFM_URL . 'assets/build/payments.css', [], SRFM_VER );
66
67 // Localize script with payment admin data.
68 wp_localize_script(
69 SRFM_SLUG . '-payments',
70 SRFM_SLUG . '_payment_admin',
71 [
72 'ajax_url' => admin_url( 'admin-ajax.php' ),
73 'srfm_payment_admin_nonce' => wp_create_nonce( 'srfm_payment_admin_nonce' ),
74 'zeroDecimalCurrencies' => Payment_Helper::get_zero_decimal_currencies(),
75 'currenciesData' => Payment_Helper::get_all_currencies_data(),
76 ]
77 );
78
79 $script_translations_handlers[] = SRFM_SLUG . '-payments';
80 }
81
82 // Register script translations if needed.
83 if ( ! empty( $script_translations_handlers ) ) {
84 foreach ( $script_translations_handlers as $script_handle ) {
85 Helper::register_script_translations( $script_handle );
86 }
87 }
88 }
89
90 /**
91 * AJAX handler for fetching payments data.
92 *
93 * @since 2.0.0
94 * @return void
95 */
96 public function fetch_payments() {
97 // Verify nonce for security.
98 if ( ! isset( $_POST['nonce'] ) || ! wp_verify_nonce( sanitize_text_field( wp_unslash( $_POST['nonce'] ) ), 'srfm_payment_admin_nonce' ) ) {
99 wp_send_json_error( [ 'message' => __( 'Security verification failed.', 'sureforms' ) ] );
100 }
101
102 // Check user capabilities.
103 if ( ! current_user_can( 'manage_options' ) ) {
104 wp_send_json_error( [ 'message' => __( 'Insufficient permissions.', 'sureforms' ) ] );
105 }
106
107 try {
108 // Sanitize input parameters.
109 $search = isset( $_POST['search'] ) ? sanitize_text_field( wp_unslash( $_POST['search'] ) ) : '';
110 $status = isset( $_POST['status'] ) ? sanitize_text_field( wp_unslash( $_POST['status'] ) ) : '';
111 $form_id = isset( $_POST['form_id'] ) ? absint( $_POST['form_id'] ) : 0;
112 $payment_mode = isset( $_POST['payment_mode'] ) ? sanitize_text_field( wp_unslash( $_POST['payment_mode'] ) ) : '';
113 $date_from = isset( $_POST['date_from'] ) ? sanitize_text_field( wp_unslash( $_POST['date_from'] ) ) : '';
114 $date_to = isset( $_POST['date_to'] ) ? sanitize_text_field( wp_unslash( $_POST['date_to'] ) ) : '';
115 $page = isset( $_POST['page'] ) ? absint( $_POST['page'] ) : 1;
116 $per_page = isset( $_POST['per_page'] ) ? absint( $_POST['per_page'] ) : 20;
117
118 // Validate pagination parameters.
119 $page = max( 1, $page );
120 $per_page = max( 1, min( 100, $per_page ) ); // Limit to 100 records per page.
121 $offset = ( $page - 1 ) * $per_page;
122
123 // Validate date format if provided.
124 if ( ! empty( $date_from ) && ! $this->validate_date( $date_from ) ) {
125 wp_send_json_error( [ 'message' => __( 'Invalid date format for date_from.', 'sureforms' ) ] );
126 }
127
128 if ( ! empty( $date_to ) && ! $this->validate_date( $date_to ) ) {
129 wp_send_json_error( [ 'message' => __( 'Invalid date format for date_to.', 'sureforms' ) ] );
130 }
131
132 // Get total count for pagination.
133 $total_count = $this->get_payments_count( $search, $status, $date_from, $date_to, $form_id, $payment_mode );
134
135 if ( 0 === $total_count && empty( $search ) && empty( $status ) && empty( $date_from ) && empty( $date_to ) && empty( $form_id ) && empty( $payment_mode ) ) {
136 wp_send_json_success(
137 [
138 'payments' => [],
139 'total' => 0,
140 'transactions_is_empty' => 'with_no_filter',
141 ]
142 );
143 }
144
145 if ( 0 === $total_count && ( ! empty( $search ) || ! empty( $status ) || ! empty( $date_from ) || ! empty( $date_to ) || ! empty( $form_id ) || ! empty( $payment_mode ) ) ) {
146 wp_send_json_success(
147 [
148 'payments' => [],
149 'total' => 0,
150 'transactions_is_empty' => 'with_filter',
151 ]
152 );
153 }
154
155 // Get payments data from database.
156 $payments = $this->get_payments_data( $search, $status, $date_from, $date_to, $per_page, $offset, $form_id, $payment_mode );
157
158 wp_send_json_success(
159 [
160 'payments' => $payments,
161 'total' => $total_count,
162 'page' => $page,
163 'per_page' => $per_page,
164 'total_pages' => ceil( $total_count / $per_page ),
165 'transactions_is_empty' => false,
166 ]
167 );
168
169 } catch ( \Exception $e ) {
170 wp_send_json_error( [ 'message' => __( 'Unable to load payments. Please refresh the page.', 'sureforms' ) ] );
171 }
172 }
173
174 /**
175 * AJAX handler for fetching single payment data.
176 *
177 * @since 2.0.0
178 * @return void
179 */
180 public function fetch_single_payment() {
181 // Verify nonce for security.
182 if ( ! isset( $_POST['nonce'] ) || ! wp_verify_nonce( sanitize_text_field( wp_unslash( $_POST['nonce'] ) ), 'srfm_payment_admin_nonce' ) ) {
183 wp_send_json_error( [ 'message' => __( 'Security verification failed.', 'sureforms' ) ] );
184 }
185
186 // Check user capabilities.
187 if ( ! current_user_can( 'manage_options' ) ) {
188 wp_send_json_error( [ 'message' => __( 'Insufficient permissions.', 'sureforms' ) ] );
189 }
190
191 // Validate payment ID.
192 $payment_id = isset( $_POST['payment_id'] ) ? absint( $_POST['payment_id'] ) : 0;
193 if ( empty( $payment_id ) ) {
194 wp_send_json_error( [ 'message' => __( 'Payment ID is required.', 'sureforms' ) ] );
195 }
196
197 try {
198 // Get single payment from database.
199 $payment = Payments::get( $payment_id );
200
201 if ( ! $payment ) {
202 wp_send_json_error( [ 'message' => __( 'Payment not found.', 'sureforms' ) ] );
203 }
204
205 // Transform payment data for frontend.
206 $payment_data = $this->transform_payment_for_frontend( $payment );
207
208 wp_send_json_success( $payment_data );
209
210 } catch ( \Exception $e ) {
211 wp_send_json_error( [ 'message' => __( 'Unable to load payment details. Please try again.', 'sureforms' ) ] );
212 }
213 }
214
215 /**
216 * AJAX handler for fetching subscription data with billing history.
217 *
218 * @since 2.0.0
219 * @return void
220 */
221 public function fetch_subscription() {
222 // Verify nonce for security.
223 if ( ! isset( $_POST['nonce'] ) || ! wp_verify_nonce( sanitize_text_field( wp_unslash( $_POST['nonce'] ) ), 'srfm_payment_admin_nonce' ) ) {
224 wp_send_json_error( [ 'message' => __( 'Security verification failed.', 'sureforms' ) ] );
225 }
226
227 // Check user capabilities.
228 if ( ! current_user_can( 'manage_options' ) ) {
229 wp_send_json_error( [ 'message' => __( 'Insufficient permissions.', 'sureforms' ) ] );
230 }
231
232 // Validate subscription ID - could be main subscription record ID or subscription_id.
233 $subscription_id = isset( $_POST['subscription_id'] ) ? sanitize_text_field( wp_unslash( $_POST['subscription_id'] ) ) : '';
234 if ( empty( $subscription_id ) ) {
235 wp_send_json_error( [ 'message' => __( 'Subscription ID is required.', 'sureforms' ) ] );
236 }
237
238 try {
239 // Check if the ID is a payment record ID first.
240 if ( is_numeric( $subscription_id ) ) {
241 $payment_record = Payments::get( absint( $subscription_id ) );
242 if ( $payment_record && 'subscription' === ( $payment_record['type'] ?? '' ) ) {
243 // This is a main subscription record ID, get the Stripe subscription ID.
244 $stripe_subscription_id = $payment_record['subscription_id'] ?? '';
245 if ( empty( $stripe_subscription_id ) ) {
246 wp_send_json_error( [ 'message' => __( 'Stripe subscription ID not found in payment record.', 'sureforms' ) ] );
247 }
248 $main_subscription = $payment_record;
249 } else {
250 wp_send_json_error( [ 'message' => __( 'Invalid subscription record.', 'sureforms' ) ] );
251 }
252 } else {
253 // This should be a Stripe subscription ID, get the main subscription record.
254 $main_subscription = Payments::get_main_subscription_record( $subscription_id );
255 if ( ! $main_subscription ) {
256 wp_send_json_error( [ 'message' => __( 'Subscription not found.', 'sureforms' ) ] );
257 }
258 $stripe_subscription_id = $subscription_id;
259 }
260
261 // Get all related billing transactions for this subscription.
262 $billing_payments = Payments::get_subscription_related_payments( $stripe_subscription_id );
263
264 // Transform main subscription data for frontend.
265 $subscription_data = $this->transform_payment_for_frontend( $main_subscription );
266
267 // Transform billing payments for frontend.
268 $billing_data = [];
269 foreach ( $billing_payments as $payment ) {
270 $billing_data[] = $this->transform_payment_for_frontend( $payment );
271 }
272
273 // Add subscription-specific fields.
274 $subscription_data['stripe_subscription_id'] = $stripe_subscription_id;
275 $subscription_data['interval'] = $this->get_subscription_interval( $main_subscription );
276 $subscription_data['next_payment_date'] = $this->get_next_payment_date( $main_subscription );
277 $subscription_data['amount_per_cycle'] = $subscription_data['total_amount']; // Use total_amount as cycle amount.
278
279 // Combine data.
280 $response_data = [
281 'subscription' => $subscription_data,
282 'payments' => $billing_data,
283 ];
284
285 /**
286 * Filter subscription details response data.
287 *
288 * Allows payment gateways (PayPal, Stripe, etc.) to add gateway-specific
289 * subscription fields to the response data sent to the frontend.
290 *
291 * @since 2.0.0
292 * @param array $response_data Response data containing subscription and payments.
293 * @param array $args Additional arguments including the main subscription record.
294 */
295 $response_data = apply_filters( 'srfm_subscription_details_response', $response_data, [ 'subscription' => $main_subscription ] );
296
297 wp_send_json_success( $response_data );
298
299 } catch ( \Exception $e ) {
300 wp_send_json_error( [ 'message' => __( 'Unable to load subscription details. Please try again.', 'sureforms' ) ] );
301 }
302 }
303
304 /**
305 * AJAX handler for fetching forms list.
306 *
307 * @since 2.0.0
308 * @return void
309 */
310 public function fetch_forms_list() {
311 // Verify nonce for security.
312 if ( ! isset( $_POST['nonce'] ) || ! wp_verify_nonce( sanitize_text_field( wp_unslash( $_POST['nonce'] ) ), 'srfm_payment_admin_nonce' ) ) {
313 wp_send_json_error( [ 'message' => __( 'Security verification failed.', 'sureforms' ) ] );
314 }
315
316 // Check user capabilities.
317 if ( ! current_user_can( 'manage_options' ) ) {
318 wp_send_json_error( [ 'message' => __( 'Insufficient permissions.', 'sureforms' ) ] );
319 }
320
321 try {
322 // Get all published forms.
323 $all_forms = \SRFM\Inc\Helper::get_sureforms();
324
325 // Get form IDs that have payments.
326 $forms_with_payments = \SRFM\Inc\Database\Tables\Payments::get_all_forms_with_payments();
327
328 // Filter forms to only include those with payments.
329 $forms_list = [];
330 foreach ( $all_forms as $form_id => $form_title ) {
331 // Only include forms that have payment records.
332 if ( in_array( (int) $form_id, $forms_with_payments, true ) ) {
333 $forms_list[] = [
334 'id' => $form_id,
335 /* translators: %d: Form ID */
336 'title' => ! empty( $form_title ) ? $form_title : sprintf( __( 'Form - #%d', 'sureforms' ), $form_id ),
337 ];
338 }
339 }
340
341 wp_send_json_success( [ 'forms' => $forms_list ] );
342
343 } catch ( \Exception $e ) {
344 wp_send_json_error( [ 'message' => __( 'Unable to load forms. Please try again.', 'sureforms' ) ] );
345 }
346 }
347
348 /**
349 * AJAX handler for adding a note to payment.
350 *
351 * @since 2.0.0
352 * @return void
353 */
354 public function ajax_add_note() {
355 // Verify nonce for security.
356 if ( ! isset( $_POST['nonce'] ) || ! wp_verify_nonce( sanitize_text_field( wp_unslash( $_POST['nonce'] ) ), 'srfm_payment_admin_nonce' ) ) {
357 wp_send_json_error( [ 'message' => __( 'Security verification failed.', 'sureforms' ) ] );
358 }
359
360 // Check user capabilities.
361 if ( ! current_user_can( 'manage_options' ) ) {
362 wp_send_json_error( [ 'message' => __( 'Insufficient permissions.', 'sureforms' ) ] );
363 }
364
365 // Validate and sanitize inputs.
366 $payment_id = isset( $_POST['payment_id'] ) ? absint( $_POST['payment_id'] ) : 0;
367 $note_text = isset( $_POST['note_text'] ) ? sanitize_textarea_field( wp_unslash( $_POST['note_text'] ) ) : '';
368
369 if ( empty( $payment_id ) ) {
370 wp_send_json_error( [ 'message' => __( 'Payment ID is required.', 'sureforms' ) ] );
371 }
372
373 if ( empty( trim( $note_text ) ) ) {
374 wp_send_json_error( [ 'message' => __( 'Note text cannot be empty.', 'sureforms' ) ] );
375 }
376
377 try {
378 // Add the note.
379 $updated_notes = $this->add_payment_note( $payment_id, $note_text );
380
381 if ( false === $updated_notes ) {
382 wp_send_json_error( [ 'message' => __( 'Failed to add note.', 'sureforms' ) ] );
383 }
384
385 wp_send_json_success( [ 'notes' => $this->get_formatted_notes( $updated_notes ) ] );
386
387 } catch ( \Exception $e ) {
388 wp_send_json_error( [ 'message' => __( 'Unable to add note. Please try again.', 'sureforms' ) ] );
389 }
390 }
391
392 /**
393 * AJAX handler for deleting a note from payment.
394 *
395 * @since 2.0.0
396 * @return void
397 */
398 public function ajax_delete_note() {
399 // Verify nonce for security.
400 if ( ! isset( $_POST['nonce'] ) || ! wp_verify_nonce( sanitize_text_field( wp_unslash( $_POST['nonce'] ) ), 'srfm_payment_admin_nonce' ) ) {
401 wp_send_json_error( [ 'message' => __( 'Security verification failed.', 'sureforms' ) ] );
402 }
403
404 // Check user capabilities.
405 if ( ! current_user_can( 'manage_options' ) ) {
406 wp_send_json_error( [ 'message' => __( 'Insufficient permissions.', 'sureforms' ) ] );
407 }
408
409 // Validate and sanitize inputs.
410 $payment_id = isset( $_POST['payment_id'] ) ? absint( $_POST['payment_id'] ) : 0;
411 $note_index = isset( $_POST['note_index'] ) ? absint( $_POST['note_index'] ) : -1;
412
413 if ( empty( $payment_id ) ) {
414 wp_send_json_error( [ 'message' => __( 'Payment ID is required.', 'sureforms' ) ] );
415 }
416
417 if ( $note_index < 0 ) {
418 wp_send_json_error( [ 'message' => __( 'Invalid note index.', 'sureforms' ) ] );
419 }
420
421 try {
422 // Delete the note.
423 $updated_notes = $this->delete_payment_note( $payment_id, $note_index );
424
425 if ( false === $updated_notes ) {
426 wp_send_json_error( [ 'message' => __( 'Failed to delete note.', 'sureforms' ) ] );
427 }
428
429 wp_send_json_success( [ 'notes' => $this->get_formatted_notes( $updated_notes ) ] );
430
431 } catch ( \Exception $e ) {
432 wp_send_json_error( [ 'message' => __( 'Unable to delete note. Please try again.', 'sureforms' ) ] );
433 }
434 }
435
436 /**
437 * AJAX handler for deleting a log entry from payment.
438 *
439 * @since 2.0.0
440 * @return void
441 */
442 public function ajax_delete_log() {
443 // Verify nonce for security.
444 if ( ! isset( $_POST['nonce'] ) || ! wp_verify_nonce( sanitize_text_field( wp_unslash( $_POST['nonce'] ) ), 'srfm_payment_admin_nonce' ) ) {
445 wp_send_json_error( [ 'message' => __( 'Security verification failed.', 'sureforms' ) ] );
446 }
447
448 // Check user capabilities.
449 if ( ! current_user_can( 'manage_options' ) ) {
450 wp_send_json_error( [ 'message' => __( 'Insufficient permissions.', 'sureforms' ) ] );
451 }
452
453 // Validate and sanitize inputs.
454 $payment_id = isset( $_POST['payment_id'] ) ? absint( $_POST['payment_id'] ) : 0;
455 $log_index = isset( $_POST['log_index'] ) ? absint( $_POST['log_index'] ) : -1;
456
457 if ( empty( $payment_id ) ) {
458 wp_send_json_error( [ 'message' => __( 'Payment ID is required.', 'sureforms' ) ] );
459 }
460
461 if ( $log_index < 0 ) {
462 wp_send_json_error( [ 'message' => __( 'Invalid log index.', 'sureforms' ) ] );
463 }
464
465 try {
466 // Delete the log entry.
467 $updated_logs = $this->delete_payment_log( $payment_id, $log_index );
468
469 if ( false === $updated_logs ) {
470 wp_send_json_error( [ 'message' => __( 'Failed to delete log entry.', 'sureforms' ) ] );
471 }
472
473 wp_send_json_success( [ 'logs' => $updated_logs ] );
474
475 } catch ( \Exception $e ) {
476 wp_send_json_error( [ 'message' => __( 'Unable to delete log. Please try again.', 'sureforms' ) ] );
477 }
478 }
479
480 /**
481 * AJAX handler for bulk deleting payments.
482 *
483 * @since 2.0.0
484 * @return void
485 */
486 public function ajax_bulk_delete_payments() {
487 // Verify nonce for security.
488 if ( ! isset( $_POST['nonce'] ) || ! wp_verify_nonce( sanitize_text_field( wp_unslash( $_POST['nonce'] ) ), 'srfm_payment_admin_nonce' ) ) {
489 wp_send_json_error( [ 'message' => __( 'Security verification failed.', 'sureforms' ) ] );
490 }
491
492 // Check user capabilities.
493 if ( ! current_user_can( 'manage_options' ) ) {
494 wp_send_json_error( [ 'message' => __( 'Insufficient permissions.', 'sureforms' ) ] );
495 }
496
497 // Get and validate payment IDs.
498 // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized -- Sanitized below after JSON decode.
499 $payment_ids_raw = isset( $_POST['payment_ids'] ) ? wp_unslash( $_POST['payment_ids'] ) : [];
500
501 // Handle JSON string or array format.
502 if ( is_string( $payment_ids_raw ) ) {
503 // Decode JSON string.
504 $payment_ids = json_decode( $payment_ids_raw, true );
505
506 // Check if JSON decode was successful.
507 if ( json_last_error() !== JSON_ERROR_NONE ) {
508 wp_send_json_error( [ 'message' => __( 'Invalid JSON format for payment IDs.', 'sureforms' ) ] );
509 }
510 } else {
511 $payment_ids = $payment_ids_raw;
512 }
513
514 // Ensure it's an array.
515 if ( ! is_array( $payment_ids ) ) {
516 wp_send_json_error( [ 'message' => __( 'Invalid payment IDs format.', 'sureforms' ) ] );
517 }
518
519 // Sanitize: Convert to integers and remove invalid values.
520 // This handles both string numbers ("169") and actual integers.
521 $payment_ids = array_map( 'absint', $payment_ids );
522 $payment_ids = array_filter(
523 $payment_ids,
524 static function ( $id ) {
525 return $id > 0;
526 }
527 );
528
529 // Re-index array to ensure sequential keys.
530 $payment_ids = array_values( $payment_ids );
531
532 // Check if array is empty after sanitization.
533 if ( empty( $payment_ids ) ) {
534 wp_send_json_error( [ 'message' => __( 'No valid payment IDs provided.', 'sureforms' ) ] );
535 }
536
537 // Limit bulk operations to prevent timeout (max 100 at once).
538 if ( count( $payment_ids ) > 100 ) {
539 wp_send_json_error(
540 [
541 'message' => __( 'Cannot delete more than 100 payments at once. Select fewer payments.', 'sureforms' ),
542 ]
543 );
544 }
545
546 try {
547 $deleted_count = 0;
548 $failed_ids = [];
549
550 // Delete each payment with proper error handling.
551 foreach ( $payment_ids as $payment_id ) {
552 // Verify payment exists before attempting delete.
553 $payment = Payments::get( $payment_id );
554
555 if ( ! $payment ) {
556 $failed_ids[] = $payment_id;
557 continue;
558 }
559
560 // Attempt deletion.
561 $result = Payments::delete( $payment_id );
562
563 if ( $result ) {
564 $deleted_count++;
565 } else {
566 $failed_ids[] = $payment_id;
567 }
568 }
569
570 // Prepare response message.
571 if ( count( $payment_ids ) === $deleted_count ) {
572 // All deleted successfully.
573 wp_send_json_success(
574 [
575 'message' => sprintf(
576 /* translators: %d: number of payments deleted */
577 _n(
578 '%d payment deleted successfully.',
579 '%d payments deleted successfully.',
580 $deleted_count,
581 'sureforms'
582 ),
583 $deleted_count
584 ),
585 'deleted_count' => $deleted_count,
586 ]
587 );
588 } elseif ( $deleted_count > 0 ) {
589 // Partial success.
590 wp_send_json_success(
591 [
592 'message' => sprintf(
593 /* translators: 1: number deleted, 2: number failed */
594 __( '%1$d payment(s) deleted successfully. %2$d failed.', 'sureforms' ),
595 $deleted_count,
596 count( $failed_ids )
597 ),
598 'deleted_count' => $deleted_count,
599 'failed_count' => count( $failed_ids ),
600 'partial' => true,
601 ]
602 );
603 } else {
604 // All failed.
605 wp_send_json_error(
606 [
607 'message' => __( 'Failed to delete payments. Please try again.', 'sureforms' ),
608 'failed_count' => count( $failed_ids ),
609 ]
610 );
611 }
612 } catch ( \Exception $e ) {
613 wp_send_json_error(
614 [
615 'message' => __( 'Unable to delete payments. Please try again.', 'sureforms' ),
616 ]
617 );
618 }
619 }
620
621 /**
622 * AJAX handler for payment refund.
623 *
624 * Gateway-agnostic refund handler that routes refund requests to the appropriate
625 * payment gateway (Stripe, PayPal, etc.) using a filter system.
626 *
627 * @since 2.0.0
628 * @return void
629 */
630 public function ajax_refund_payment() {
631 // Verify nonce for security.
632 if (
633 ! wp_verify_nonce(
634 sanitize_text_field( wp_unslash( $_POST['nonce'] ?? '' ) ),
635 'srfm_payment_admin_nonce'
636 )
637 ) {
638 wp_send_json_error( __( 'Invalid nonce.', 'sureforms' ) );
639 }
640
641 // Check if user has permission to refund payments.
642 if ( ! current_user_can( 'manage_options' ) ) {
643 wp_send_json_error( __( 'Insufficient permissions.', 'sureforms' ) );
644 }
645
646 // Get and validate input parameters.
647 $payment_id = isset( $_POST['payment_id'] ) ? absint( $_POST['payment_id'] ) : 0;
648 $transaction_id = isset( $_POST['transaction_id'] ) ? sanitize_text_field( wp_unslash( $_POST['transaction_id'] ) ) : '';
649 $refund_amount = isset( $_POST['refund_amount'] ) ? absint( $_POST['refund_amount'] ) : 0;
650 $refund_notes = isset( $_POST['refund_notes'] ) ? sanitize_textarea_field( wp_unslash( $_POST['refund_notes'] ) ) : '';
651
652 // Validate required parameters.
653 if ( empty( $payment_id ) || empty( $transaction_id ) || $refund_amount <= 0 ) {
654 wp_send_json_error( __( 'Invalid payment data.', 'sureforms' ) );
655 }
656
657 try {
658 // Get payment from database.
659 $payment = Payments::get( $payment_id );
660 if ( ! $payment ) {
661 wp_send_json_error( __( 'Payment not found.', 'sureforms' ) );
662 }
663
664 // Get payment gateway (stripe, paypal, etc.).
665 $gateway = isset( $payment['gateway'] ) && is_string( $payment['gateway'] ) ? $payment['gateway'] : '';
666
667 if ( empty( $gateway ) ) {
668 wp_send_json_error( __( 'Payment gateway not found.', 'sureforms' ) );
669 }
670
671 /**
672 * Filter: srfm_process_transaction_refund
673 *
674 * Allows payment gateways to process refunds for their transactions.
675 * Gateway handlers should hook into this filter and check if the gateway matches theirs.
676 *
677 * @since 2.0.0
678 *
679 * @param array<string,mixed> $refund_result {
680 * Refund result array. Gateway handlers should return success/error status.
681 *
682 * @type bool $success Whether the refund was successful.
683 * @type string $message Success or error message.
684 * @type array $data Optional. Additional data like refund_id, status, etc.
685 * }
686 * @param array<string,mixed> $refund_args {
687 * Arguments passed to gateway refund handlers.
688 *
689 * @type array $payment Full payment record from database.
690 * @type int $payment_id Payment record ID.
691 * @type string $transaction_id Transaction/charge ID from gateway.
692 * @type int $refund_amount Refund amount in smallest currency unit (cents for USD).
693 * @type string $refund_notes Optional refund notes/reason.
694 * @type string $gateway Payment gateway identifier (stripe, paypal, etc.).
695 * }
696 */
697 $refund_result = apply_filters(
698 'srfm_process_transaction_refund',
699 [
700 'success' => false,
701 'message' => sprintf(
702 /* translators: %s: payment gateway name */
703 __( 'Refund processing is not supported for %s gateway.', 'sureforms' ),
704 ucfirst( $gateway )
705 ),
706 'data' => [],
707 ],
708 [
709 'payment' => $payment,
710 'payment_id' => $payment_id,
711 'transaction_id' => $transaction_id,
712 'refund_amount' => $refund_amount,
713 'refund_notes' => $refund_notes,
714 'gateway' => $gateway,
715 ]
716 );
717
718 // Check if refund was successful.
719 if ( ! empty( $refund_result['success'] ) && true === $refund_result['success'] ) {
720 $result_data = isset( $refund_result['data'] ) && is_array( $refund_result['data'] ) ? $refund_result['data'] : [];
721 wp_send_json_success(
722 [
723 'message' => ! empty( $refund_result['message'] ) ? $refund_result['message'] : __( 'Payment refunded successfully.', 'sureforms' ),
724 'refund_id' => ! empty( $result_data['refund_id'] ) ? $result_data['refund_id'] : '',
725 'status' => ! empty( $result_data['status'] ) ? $result_data['status'] : 'processed',
726 ]
727 );
728 } else {
729 // Refund failed - return error message.
730 wp_send_json_error(
731 ! empty( $refund_result['message'] )
732 ? $refund_result['message']
733 : __( 'Failed to process refund. Please try again.', 'sureforms' )
734 );
735 }
736 } catch ( \Exception $e ) {
737 wp_send_json_error( __( 'Unable to process refund. Please try again.', 'sureforms' ) );
738 }
739 }
740
741 /**
742 * Get payments data based on filters.
743 *
744 * @param string $search Search term.
745 * @param string $status Payment status filter.
746 * @param string $date_from Start date filter.
747 * @param string $date_to End date filter.
748 * @param int $limit Number of records to return.
749 * @param int $offset Number of records to skip.
750 * @param int $form_id Form ID filter.
751 * @param string $payment_mode Payment mode filter (test/live).
752 * @since 2.0.0
753 * @return array Filtered payments data.
754 */
755 private function get_payments_data( $search = '', $status = '', $date_from = '', $date_to = '', $limit = 20, $offset = 0, $form_id = 0, $payment_mode = '' ) {
756 // Build WHERE conditions for database query.
757 $where_conditions = [];
758
759 // Add search filter - search in form names, customer data, etc.
760 if ( ! empty( $search ) ) {
761 global $wpdb;
762 $search_term = '%' . $wpdb->esc_like( $search ) . '%';
763 $where_conditions[] = [
764 [
765 'key' => 'id',
766 'compare' => 'IN',
767 'value' => $this->get_payment_ids_by_search( $search_term ),
768 ],
769 ];
770 }
771
772 // Add status filter - map frontend status to database status.
773 if ( ! empty( $status ) ) {
774 $status_clause = $this->build_status_where_clause( $status );
775 if ( ! empty( $status_clause ) ) {
776 $where_conditions[] = $status_clause;
777 }
778 }
779
780 // Add form_id filter.
781 if ( ! empty( $form_id ) ) {
782 $where_conditions[] = [
783 [
784 'key' => 'form_id',
785 'compare' => '=',
786 'value' => $form_id,
787 ],
788 ];
789 }
790
791 // Add payment mode filter (test/live).
792 if ( ! empty( $payment_mode ) && in_array( $payment_mode, [ 'test', 'live' ], true ) ) {
793 $where_conditions[] = [
794 [
795 'key' => 'mode',
796 'compare' => '=',
797 'value' => $payment_mode,
798 ],
799 ];
800 }
801
802 // Add date range filter.
803 if ( ! empty( $date_from ) || ! empty( $date_to ) ) {
804 if ( ! empty( $date_from ) && ! empty( $date_to ) ) {
805 $where_conditions[] = [
806 [
807 'key' => 'created_at',
808 'compare' => '>=',
809 'value' => $date_from . ' 00:00:00',
810 ],
811 [
812 'key' => 'created_at',
813 'compare' => '<=',
814 'value' => $date_to . ' 23:59:59',
815 ],
816 ];
817 } elseif ( ! empty( $date_from ) ) {
818 $where_conditions[] = [
819 [
820 'key' => 'created_at',
821 'compare' => '>=',
822 'value' => $date_from . ' 00:00:00',
823 ],
824 ];
825 } elseif ( ! empty( $date_to ) ) {
826 $where_conditions[] = [
827 [
828 'key' => 'created_at',
829 'compare' => '<=',
830 'value' => $date_to . ' 23:59:59',
831 ],
832 ];
833 }
834 }
835
836 // Get payments from database using the main payments method.
837 // Sorted by created_at in descending order (newest first).
838 $args = [
839 'where' => $where_conditions,
840 'limit' => $limit,
841 'offset' => $offset,
842 'orderby' => 'created_at',
843 'order' => 'DESC',
844 ];
845
846 $db_payments = Payments::get_all_main_payments( $args, true );
847
848 // Transform database records to frontend format.
849 $formatted_payments = [];
850 foreach ( $db_payments as $payment ) {
851 $formatted_payments[] = $this->transform_payment_for_frontend( $payment );
852 }
853
854 return $formatted_payments;
855 }
856
857 /**
858 * Get payment IDs that match search criteria.
859 *
860 * @param string $search_term Search term with wildcards.
861 * @since 2.0.0
862 * @return array Array of payment IDs.
863 */
864 private function get_payment_ids_by_search( $search_term ) {
865 global $wpdb;
866
867 // Get payments table name.
868 $payments_table = Payments::get_instance()->get_tablename();
869
870 if ( empty( $payments_table ) || ! is_string( $payments_table ) ) {
871 return [ 0 ];
872 }
873
874 // phpcs:disable WordPress.DB.PreparedSQL.InterpolatedNotPrepared
875 // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, PluginCheck.Security.DirectDB.UnescapedDBParameter -- Custom table query for search; table name from get_tablename() and validated (not user input); cannot be parameterized with prepare().
876 $results = $wpdb->get_col(
877 $wpdb->prepare(
878 "SELECT DISTINCT id FROM {$payments_table}
879 WHERE id LIKE %s
880 OR customer_name LIKE %s
881 OR customer_email LIKE %s
882 OR transaction_id LIKE %s
883 OR srfm_txn_id LIKE %s",
884 $search_term,
885 $search_term,
886 $search_term,
887 $search_term,
888 $search_term
889 )
890 );
891 // phpcs:enable WordPress.DB.PreparedSQL.InterpolatedNotPrepared
892
893 // If no results found, return array with 0 to prevent empty IN clause.
894 return ! empty( $results ) ? array_map( 'absint', $results ) : [ 0 ];
895 }
896
897 /**
898 * Map frontend status to database status.
899 *
900 * @param string $frontend_status Status from frontend.
901 * @since 2.0.0
902 * @return string|false Database status or false if invalid.
903 */
904 private function map_frontend_status_to_db( $frontend_status ) {
905 $status_mapping = [
906 'succeeded' => 'succeeded',
907 'partially_refunded' => 'partially_refunded',
908 'pending' => 'pending',
909 'failed' => 'failed',
910 'refunded' => 'refunded',
911 'cancelled' => 'canceled',
912 ];
913
914 return $status_mapping[ $frontend_status ] ?? false;
915 }
916
917 /**
918 * Map database status to frontend status.
919 *
920 * @param string $db_status Status from database.
921 * @since 2.0.0
922 * @return string Frontend status.
923 */
924 private function map_db_status_to_frontend( $db_status ) {
925 $status_mapping = [
926 'succeeded' => 'paid',
927 'pending' => 'pending',
928 'failed' => 'failed',
929 'refunded' => 'refunded',
930 'partially_refunded' => 'refunded',
931 'canceled' => 'canceled',
932 'requires_action' => 'pending',
933 'requires_payment_method' => 'pending',
934 'processing' => 'pending',
935 ];
936
937 return $status_mapping[ $db_status ] ?? $db_status;
938 }
939
940 /**
941 * Transform database payment record to frontend format.
942 *
943 * @param array<mixed> $payment Database payment record.
944 * @since 2.0.0
945 * @return array Transformed payment data.
946 */
947 private function transform_payment_for_frontend( $payment ) {
948 static $form_titles = []; // Cache for form titles.
949
950 // Get form title with caching using WordPress built-in function.
951 $form_id = isset( $payment['form_id'] ) && ! empty( $payment['form_id'] ) && is_numeric( $payment['form_id'] ) ? intval( $payment['form_id'] ) : 0;
952 if ( is_numeric( $form_id ) && ! isset( $form_titles[ $form_id ] ) ) {
953 $form_title = get_the_title( intval( $form_id ) );
954 $form_titles[ $form_id ] = ! empty( $form_title ) ? html_entity_decode( $form_title, ENT_QUOTES | ENT_HTML5, 'UTF-8' ) : __( 'Unknown Form', 'sureforms' );
955 }
956 $form_title = isset( $form_titles[ $form_id ] ) && ! empty( $form_titles[ $form_id ] ) ? $form_titles[ $form_id ] : __( 'Unknown Form', 'sureforms' );
957 $form_permalink = isset( $form_titles[ $form_id ] ) && ! empty( $form_titles[ $form_id ] ) ? get_permalink( intval( $form_id ) ) : '';
958 $form_url = ! empty( $form_permalink ) && is_string( $form_permalink ) ? html_entity_decode( $form_permalink ) : '';
959
960 // Get customer name - for now use customer_id, in real implementation.
961 // You would get customer data from entries or payment_data.
962 $customer_name = ! empty( $payment['customer_name'] ) ? $payment['customer_name'] : __( 'N/A', 'sureforms' );
963 $customer_email = ! empty( $payment['customer_email'] ) ? $payment['customer_email'] : __( 'N/A', 'sureforms' );
964 $notes = Payments::get_extra_value( $payment['id'], 'notes', [] );
965 $notes = ! empty( $notes ) && is_array( $notes ) ? $notes : [];
966
967 // Determine payment type display label.
968 if ( 'subscription' === $payment['type'] ) {
969 $payment_type = __( 'Subscription', 'sureforms' );
970 } elseif ( 'renewal' === $payment['type'] ) {
971 $payment_type = __( 'Renewal', 'sureforms' );
972 } else {
973 $payment_type = __( 'One Time', 'sureforms' );
974 }
975
976 $payment_front_end_data = [
977 // All original payment_data fields.
978 'id' => $payment['id'],
979 'form_id' => $payment['form_id'],
980 'block_id' => $payment['block_id'] ?? '',
981 'status' => $payment['status'],
982 'total_amount' => $payment['total_amount'],
983 'refunded_amount' => $payment['refunded_amount'] ?? '0.00000000',
984 'currency' => $payment['currency'],
985 'entry_id' => $payment['entry_id'] ?? '',
986 'gateway' => $payment['gateway'],
987 'type' => $payment['type'],
988 'mode' => $payment['mode'] ?? '',
989 'transaction_id' => $payment['transaction_id'] ?? '',
990 'customer_id' => $payment['customer_id'] ?? '',
991 'subscription_id' => $payment['subscription_id'] ?? '',
992 'subscription_status' => $payment['subscription_status'] ?? '',
993 'parent_subscription_id' => $payment['parent_subscription_id'] ?? '0',
994 'payment_data' => $payment['payment_data'] ?? '{}',
995 'extra' => $payment['extra'] ?? '[]',
996 'log' => $payment['log'] ?? '[]',
997 'created_at' => $payment['created_at'],
998 'updated_at' => $payment['updated_at'],
999 'srfm_txn_id' => $payment['srfm_txn_id'],
1000
1001 // Additional frontend fields.
1002 'form_title' => $form_title,
1003 'form_url' => $form_url,
1004 'form' => $form_title, // Keep for backward compatibility.
1005 'customer_name' => $customer_name,
1006 'customer_email' => $customer_email,
1007 'amount' => floatval( $payment['total_amount'] ),
1008 'frontend_status' => $this->map_db_status_to_frontend( $payment['status'] ),
1009 'datetime' => $payment['created_at'], // Keep for backward compatibility.
1010 'payment_type' => $payment_type,
1011 'notes' => $this->get_formatted_notes( $notes ),
1012 'logs' => $this->get_formatted_logs( $payment['log'] ),
1013 ];
1014
1015 return apply_filters( 'srfm_payment_admin_data', $payment_front_end_data, $payment );
1016 }
1017
1018 /**
1019 * Get total count of payments with filters.
1020 *
1021 * @param string $search Search term.
1022 * @param string $status Payment status filter.
1023 * @param string $date_from Start date filter.
1024 * @param string $date_to End date filter.
1025 * @param int $form_id Form ID filter.
1026 * @param string $payment_mode Payment mode filter (test/live).
1027 * @since 2.0.0
1028 * @return int Total count.
1029 */
1030 private function get_payments_count( $search = '', $status = '', $date_from = '', $date_to = '', $form_id = 0, $payment_mode = '' ) {
1031 // Build WHERE conditions similar to get_payments_data.
1032 $where_conditions = [];
1033
1034 if ( ! empty( $search ) ) {
1035 global $wpdb;
1036 $search_term = '%' . $wpdb->esc_like( $search ) . '%';
1037 $where_conditions[] = [
1038 [
1039 'key' => 'id',
1040 'compare' => 'IN',
1041 'value' => $this->get_payment_ids_by_search( $search_term ),
1042 ],
1043 ];
1044 }
1045
1046 if ( ! empty( $status ) ) {
1047 $status_clause = $this->build_status_where_clause( $status );
1048 if ( ! empty( $status_clause ) ) {
1049 $where_conditions[] = $status_clause;
1050 }
1051 }
1052
1053 // Add form_id filter.
1054 if ( ! empty( $form_id ) ) {
1055 $where_conditions[] = [
1056 [
1057 'key' => 'form_id',
1058 'compare' => '=',
1059 'value' => $form_id,
1060 ],
1061 ];
1062 }
1063
1064 // Add payment mode filter (test/live).
1065 if ( ! empty( $payment_mode ) && in_array( $payment_mode, [ 'test', 'live' ], true ) ) {
1066 $where_conditions[] = [
1067 [
1068 'key' => 'mode',
1069 'compare' => '=',
1070 'value' => $payment_mode,
1071 ],
1072 ];
1073 }
1074
1075 if ( ! empty( $date_from ) || ! empty( $date_to ) ) {
1076 if ( ! empty( $date_from ) && ! empty( $date_to ) ) {
1077 $where_conditions[] = [
1078 [
1079 'key' => 'created_at',
1080 'compare' => '>=',
1081 'value' => $date_from . ' 00:00:00',
1082 ],
1083 [
1084 'key' => 'created_at',
1085 'compare' => '<=',
1086 'value' => $date_to . ' 23:59:59',
1087 ],
1088 ];
1089 } elseif ( ! empty( $date_from ) ) {
1090 $where_conditions[] = [
1091 [
1092 'key' => 'created_at',
1093 'compare' => '>=',
1094 'value' => $date_from . ' 00:00:00',
1095 ],
1096 ];
1097 } elseif ( ! empty( $date_to ) ) {
1098 $where_conditions[] = [
1099 [
1100 'key' => 'created_at',
1101 'compare' => '<=',
1102 'value' => $date_to . ' 23:59:59',
1103 ],
1104 ];
1105 }
1106 }
1107
1108 return Payments::get_total_main_payments_by_status( 'all', 0, $where_conditions );
1109 }
1110
1111 /**
1112 * Build the WHERE clause group for the frontend status filter.
1113 *
1114 * For 'cancelled' the parent subscription row keeps its transaction
1115 * `status` (e.g. 'succeeded'/'active') after cancellation, with the
1116 * lifecycle tracked on `subscription_status`. To keep canceled
1117 * subscriptions findable via the dropdown — and stay backward
1118 * compatible with legacy rows that still have `status='canceled'` — we
1119 * match either column.
1120 *
1121 * @param string $frontend_status Status value from the frontend filter.
1122 * @since 2.9.0
1123 * @return array<int|string,mixed> WHERE clause group, or empty array if invalid.
1124 */
1125 private function build_status_where_clause( $frontend_status ) {
1126 $db_status = $this->map_frontend_status_to_db( $frontend_status );
1127 if ( ! $db_status ) {
1128 return [];
1129 }
1130
1131 if ( 'canceled' === $db_status ) {
1132 return [
1133 'RELATION' => 'OR',
1134 [
1135 'key' => 'status',
1136 'compare' => '=',
1137 'value' => 'canceled',
1138 ],
1139 [
1140 'key' => 'subscription_status',
1141 'compare' => '=',
1142 'value' => 'canceled',
1143 ],
1144 ];
1145 }
1146
1147 return [
1148 [
1149 'key' => 'status',
1150 'compare' => '=',
1151 'value' => $db_status,
1152 ],
1153 ];
1154 }
1155
1156 /**
1157 * Get subscription billing interval from payment data.
1158 *
1159 * @param array<mixed> $subscription_record Main subscription payment record.
1160 * @since 2.0.0
1161 * @return string Billing interval.
1162 */
1163 private function get_subscription_interval( $subscription_record ) {
1164 // Try to get interval from payment_data.
1165 if ( ! empty( $subscription_record['payment_data'] ) ) {
1166 $payment_data = \SRFM\Inc\Helper::get_array_value( $subscription_record['payment_data'] );
1167
1168 // Check various possible locations for interval data.
1169 $interval_paths = [
1170 'subscription.items.data.0.price.recurring.interval',
1171 'subscription.plan.interval',
1172 'price.recurring.interval',
1173 'plan.interval',
1174 'interval',
1175 ];
1176
1177 foreach ( $interval_paths as $path ) {
1178 $interval = $this->get_nested_array_value( $payment_data, $path );
1179 if ( ! empty( $interval ) && is_string( $interval ) ) {
1180 return ucfirst( $interval ); // month -> Month, year -> Year.
1181 }
1182 }
1183 }
1184
1185 return __( 'Unknown', 'sureforms' );
1186 }
1187
1188 /**
1189 * Get next payment date from subscription data.
1190 *
1191 * @param array<mixed> $subscription_record Main subscription payment record.
1192 * @since 2.0.0
1193 * @return string|null Next payment date or null.
1194 */
1195 private function get_next_payment_date( $subscription_record ) {
1196 // Try to get next payment date from payment_data.
1197 if ( ! empty( $subscription_record['payment_data'] ) ) {
1198 $payment_data = \SRFM\Inc\Helper::get_array_value( $subscription_record['payment_data'] );
1199
1200 // Check various possible locations for next payment date.
1201 $date_paths = [
1202 'subscription.current_period_end',
1203 'current_period_end',
1204 'next_payment_attempt',
1205 ];
1206
1207 foreach ( $date_paths as $path ) {
1208 $timestamp = $this->get_nested_array_value( $payment_data, $path );
1209 if ( ! empty( $timestamp ) && is_numeric( $timestamp ) ) {
1210 $timestamp_int = intval( $timestamp );
1211 return gmdate( 'Y-m-d H:i:s', $timestamp_int );
1212 }
1213 }
1214 }
1215
1216 return null;
1217 }
1218
1219 /**
1220 * Get nested value from array using dot notation.
1221 *
1222 * @param array<mixed> $array Array to search.
1223 * @param string $path Dot-separated path.
1224 * @since 2.0.0
1225 * @return mixed Value or null if not found.
1226 */
1227 private function get_nested_array_value( $array, $path ) {
1228 $keys = explode( '.', $path );
1229 $current = $array;
1230
1231 foreach ( $keys as $key ) {
1232 if ( ! is_array( $current ) || ! isset( $current[ $key ] ) ) {
1233 return null;
1234 }
1235 $current = $current[ $key ];
1236 }
1237
1238 return $current;
1239 }
1240
1241 /**
1242 * Validate date format (YYYY-MM-DD).
1243 *
1244 * @param string $date Date string to validate.
1245 * @since 2.0.0
1246 * @return bool True if valid, false otherwise.
1247 */
1248 private function validate_date( $date ) {
1249 $parsed_date = \DateTime::createFromFormat( 'Y-m-d', $date );
1250 return $parsed_date && $parsed_date->format( 'Y-m-d' ) === $date;
1251 }
1252
1253 /**
1254 * Add a note to payment.
1255 *
1256 * @param int $payment_id Payment ID.
1257 * @param string $note_text Note text to add.
1258 * @since 2.0.0
1259 * @return array|false Updated notes array or false on failure.
1260 */
1261 private function add_payment_note( $payment_id, $note_text ) {
1262 if ( empty( $payment_id ) || empty( trim( $note_text ) ) ) {
1263 return false;
1264 }
1265
1266 // Verify payment exists.
1267 $payment = Payments::get( $payment_id );
1268 if ( ! $payment ) {
1269 return false;
1270 }
1271
1272 // Get current notes from extra data.
1273 $notes = Payments::get_extra_value( $payment_id, 'notes', [] );
1274
1275 // Ensure notes is an array.
1276 if ( ! is_array( $notes ) ) {
1277 $notes = [];
1278 }
1279
1280 // Create new note with metadata.
1281 $new_note = [
1282 'text' => trim( $note_text ),
1283 'created_at' => current_time( 'mysql' ),
1284 'created_by' => get_current_user_id(),
1285 ];
1286
1287 // Add new note to the beginning of the array (most recent first).
1288 array_unshift( $notes, $new_note );
1289
1290 // Update extra data with new notes array.
1291 $result = Payments::update_extra_key( $payment_id, 'notes', $notes );
1292
1293 if ( false === $result ) {
1294 return false;
1295 }
1296
1297 return $notes;
1298 }
1299
1300 /**
1301 * Delete a note from payment by index.
1302 *
1303 * @param int $payment_id Payment ID.
1304 * @param int $note_index Index of note to delete.
1305 * @since 2.0.0
1306 * @return array|false Updated notes array or false on failure.
1307 */
1308 private function delete_payment_note( $payment_id, $note_index ) {
1309 if ( empty( $payment_id ) || $note_index < 0 ) {
1310 return false;
1311 }
1312
1313 // Verify payment exists.
1314 $payment = Payments::get( $payment_id );
1315 if ( ! $payment ) {
1316 return false;
1317 }
1318
1319 // Get current notes.
1320 $notes = Payments::get_extra_value( $payment_id, 'notes', [] );
1321
1322 // Ensure notes is an array.
1323 if ( ! is_array( $notes ) ) {
1324 return false;
1325 }
1326
1327 // Check if note index exists.
1328 if ( ! isset( $notes[ $note_index ] ) ) {
1329 return false;
1330 }
1331
1332 // Remove note at specified index.
1333 array_splice( $notes, $note_index, 1 );
1334
1335 // Re-index array to prevent gaps.
1336 $notes = array_values( $notes );
1337
1338 // Update extra data with modified notes array.
1339 $result = Payments::update_extra_key( $payment_id, 'notes', $notes );
1340
1341 if ( false === $result ) {
1342 return false;
1343 }
1344
1345 return $notes;
1346 }
1347
1348 /**
1349 * Delete a log entry from payment by index.
1350 *
1351 * @param int $payment_id Payment ID.
1352 * @param int $log_index Index of log entry to delete.
1353 * @since 2.0.0
1354 * @return array|false Updated formatted logs array or false on failure.
1355 */
1356 private function delete_payment_log( $payment_id, $log_index ) {
1357 if ( empty( $payment_id ) || $log_index < 0 ) {
1358 return false;
1359 }
1360
1361 // Verify payment exists.
1362 $payment = Payments::get( $payment_id );
1363 if ( ! $payment ) {
1364 return false;
1365 }
1366
1367 // Get current logs from log column.
1368 $logs_data = $payment['log'] ?? '[]';
1369 $logs = json_decode( $logs_data, true ) ?? [];
1370
1371 // Ensure logs is an array.
1372 if ( ! is_array( $logs ) ) {
1373 return false;
1374 }
1375
1376 // Check if log index exists.
1377 if ( ! isset( $logs[ $log_index ] ) ) {
1378 return false;
1379 }
1380
1381 // Remove log at specified index.
1382 array_splice( $logs, $log_index, 1 );
1383
1384 // Re-index array to prevent gaps.
1385 $logs = array_values( $logs );
1386
1387 // Update log column with modified logs array.
1388 $result = Payments::update(
1389 $payment_id,
1390 [
1391 'log' => wp_json_encode( $logs ),
1392 ]
1393 );
1394
1395 if ( false === $result ) {
1396 return false;
1397 }
1398
1399 $encoded_logs = wp_json_encode( $logs );
1400 $encoded_logs = is_string( $encoded_logs ) ? $encoded_logs : '';
1401
1402 // Return formatted logs for frontend.
1403 return $this->get_formatted_logs( $encoded_logs );
1404 }
1405
1406 /**
1407 * Get formatted logs from log data.
1408 *
1409 * @param string $log_data JSON encoded log data.
1410 * @since 2.0.0
1411 * @return array Formatted logs array.
1412 */
1413 private function get_formatted_logs( $log_data ) {
1414 if ( empty( $log_data ) ) {
1415 return [];
1416 }
1417
1418 // If already an array, use it directly.
1419 if ( is_array( $log_data ) ) {
1420 $logs = $log_data;
1421 } else {
1422 return [];
1423 }
1424
1425 if ( ! is_array( $logs ) ) {
1426 return [];
1427 }
1428
1429 $formatted_logs = [];
1430
1431 foreach ( $logs as $log ) {
1432 // Handle both object and array formats.
1433 if ( is_object( $log ) ) {
1434 $formatted_logs[] = [
1435 'title' => $log->title ?? '',
1436 'created_at' => $log->created_at ?? 0,
1437 'messages' => $log->messages ?? [],
1438 ];
1439 } elseif ( is_array( $log ) ) {
1440 $formatted_logs[] = [
1441 'title' => $log['title'] ?? '',
1442 'created_at' => $log['created_at'] ?? 0,
1443 'messages' => $log['messages'] ?? [],
1444 ];
1445 }
1446 }
1447
1448 return $formatted_logs;
1449 }
1450
1451 /**
1452 * Get formatted notes from notes data.
1453 *
1454 * @param array<mixed> $notes_data Notes array from extra data.
1455 * @since 2.0.0
1456 * @return array Formatted notes array.
1457 */
1458 private function get_formatted_notes( $notes_data ) {
1459 if ( empty( $notes_data ) ) {
1460 return [];
1461 }
1462
1463 // If not an array, return empty.
1464 if ( ! is_array( $notes_data ) ) {
1465 return [];
1466 }
1467
1468 $formatted_notes = [];
1469
1470 foreach ( $notes_data as $note ) {
1471 // Handle both object and array formats.
1472 if ( is_object( $note ) ) {
1473 $formatted_notes[] = [
1474 'text' => $note->text ?? '',
1475 'created_at' => $note->created_at ?? '',
1476 'created_by' => $note->created_by ?? 0,
1477 'created_by_user_name' => isset( $note->created_by ) && is_numeric( $note->created_by ) ? $this->get_user_detail_by_id( $note->created_by ) : __( 'Guest User', 'sureforms' ),
1478 ];
1479 } elseif ( is_array( $note ) ) {
1480 $formatted_notes[] = [
1481 'text' => $note['text'] ?? '',
1482 'created_at' => $note['created_at'] ?? '',
1483 'created_by' => $note['created_by'] ?? 0,
1484 'created_by_user_name' => isset( $note['created_by'] ) && is_numeric( $note['created_by'] ) ? $this->get_user_detail_by_id( $note['created_by'] ) : __( 'Guest User', 'sureforms' ),
1485 ];
1486 }
1487 }
1488
1489 return $formatted_notes;
1490 }
1491
1492 /**
1493 * Get user detail by ID.
1494 *
1495 * @param mixed $user_id User ID.
1496 * @since 2.0.0
1497 * @return string User name or 'Guest User' if user not found.
1498 */
1499 private function get_user_detail_by_id( $user_id ) {
1500 if ( empty( $user_id ) || ! is_numeric( $user_id ) || $user_id <= 0 ) {
1501 return __( 'Guest User', 'sureforms' );
1502 }
1503 $user_id = is_numeric( $user_id ) ? intval( $user_id ) : 0;
1504 if ( $user_id <= 0 ) {
1505 return __( 'Guest User', 'sureforms' );
1506 }
1507 $user = get_userdata( $user_id );
1508 $user_name = $user ? $user->user_login : '';
1509 return is_string( $user_name ) ? $user_name : __( 'Guest User', 'sureforms' );
1510 }
1511 }
1512