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.5 2.12.4 2.12.3 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 / payment-history-shortcode.php
sureforms / inc / payments Last commit date
admin 2 months ago stripe 2 months ago front-end.php 1 month ago payment-helper.php 2 months ago payment-history-shortcode.php 4 months ago payments.php 4 months ago
payment-history-shortcode.php
1098 lines
1 <?php
2 /**
3 * Payment History Shortcode.
4 *
5 * Renders a modern payment dashboard with subscriptions section, payment history,
6 * and overlay detail panels for logged-in users.
7 *
8 * @package sureforms
9 * @since 2.8.0
10 */
11
12 namespace SRFM\Inc\Payments;
13
14 use SRFM\Inc\Database\Tables\Payments;
15 use SRFM\Inc\Traits\Get_Instance;
16
17 if ( ! defined( 'ABSPATH' ) ) {
18 exit; // Exit if accessed directly.
19 }
20
21 /**
22 * Payment History Shortcode class.
23 *
24 * @since 2.8.0
25 */
26 class Payment_History_Shortcode {
27 use Get_Instance;
28
29 /**
30 * Shortcode tag.
31 */
32 public const SHORTCODE_TAG = 'srfm_payment_history';
33
34 /**
35 * Constructor.
36 *
37 * @since 2.8.0
38 */
39 public function __construct() {
40 add_shortcode( self::SHORTCODE_TAG, [ $this, 'render' ] );
41 add_action( 'wp_enqueue_scripts', [ $this, 'enqueue_assets' ] );
42
43 // Frontend AJAX handlers.
44 add_action( 'wp_ajax_srfm_frontend_cancel_subscription', [ $this, 'ajax_cancel_subscription' ] );
45 }
46
47 /**
48 * Conditionally enqueue assets when the shortcode is present on the page.
49 *
50 * Also called at render time (shortcode/block callback) to support
51 * FSE themes and page builders where global $post is unavailable.
52 *
53 * CSS is always enqueued during wp_enqueue_scripts (lightweight, prevents FOUC
54 * on page builders like Elementor/Bricks that store content in postmeta).
55 * JS + localized data are only enqueued when the shortcode/block is detected.
56 *
57 * @since 2.8.0
58 * @return void
59 */
60 public function enqueue_assets() {
61 $file_prefix = defined( 'SRFM_DEBUG' ) && SRFM_DEBUG ? '' : '.min';
62 $dir_name = defined( 'SRFM_DEBUG' ) && SRFM_DEBUG ? 'unminified' : 'minified';
63
64 // Always enqueue CSS during wp_enqueue_scripts to ensure it loads in <head>.
65 // WordPress silently ignores late-enqueued styles (after wp_head), so page builders
66 // like Elementor, Bricks, and Beaver Builder (which store content in postmeta, not
67 // post_content) would get no CSS at all if we only enqueued conditionally.
68 // The CSS file is lightweight — one small stylesheet on frontend pages is acceptable.
69 if ( doing_action( 'wp_enqueue_scripts' ) && ! wp_style_is( 'srfm-payment-history', 'enqueued' ) ) {
70 wp_enqueue_style(
71 'srfm-payment-history',
72 SRFM_URL . 'assets/css/' . $dir_name . '/payment-history' . $file_prefix . '.css',
73 [],
74 SRFM_VER
75 );
76 }
77
78 // JS + localized data: only enqueue when the shortcode/block is actually present.
79 // JS can be late-enqueued (footer scripts) but CSS cannot, hence the split above.
80 if ( wp_script_is( 'srfm-payment-history', 'enqueued' ) ) {
81 return;
82 }
83
84 // When called from the wp_enqueue_scripts hook, check if the shortcode/block is present.
85 // When called from render(), we know it's needed — skip the check.
86 if ( doing_action( 'wp_enqueue_scripts' ) ) {
87 global $post;
88
89 if ( ! $post instanceof \WP_Post ) {
90 return;
91 }
92
93 $has_shortcode = has_shortcode( $post->post_content, self::SHORTCODE_TAG );
94 $has_block = has_block( 'srfm/payment-history', $post );
95
96 if ( ! $has_shortcode && ! $has_block ) {
97 return;
98 }
99 }
100
101 wp_enqueue_script(
102 'srfm-payment-history',
103 SRFM_URL . 'assets/js/payment-history.js',
104 [],
105 SRFM_VER,
106 true
107 );
108
109 wp_localize_script(
110 'srfm-payment-history',
111 'srfm_payment_history',
112 [
113 'ajax_url' => admin_url( 'admin-ajax.php' ),
114 'nonce' => wp_create_nonce( 'srfm_frontend_payment_nonce' ),
115 'i18n' => $this->get_i18n_strings(),
116 ]
117 );
118 }
119
120 /**
121 * Render the payment history shortcode.
122 *
123 * @param array<string,string>|string $atts Shortcode attributes.
124 * @since 2.8.0
125 * @return string HTML output.
126 */
127 public function render( $atts ) {
128 $atts = shortcode_atts(
129 [
130 'per_page' => '10',
131 'show_subscription' => 'true',
132 ],
133 is_array( $atts ) ? $atts : [],
134 self::SHORTCODE_TAG
135 );
136
137 $per_page = absint( $atts['per_page'] );
138 if ( $per_page <= 0 ) {
139 $per_page = 10;
140 }
141
142 if ( ! is_user_logged_in() ) {
143 return $this->get_login_message();
144 }
145
146 // Enqueue assets at render time — handles FSE themes, Elementor, and
147 // other page builders where global $post is unavailable during wp_enqueue_scripts.
148 $this->enqueue_assets();
149
150 $user_id = get_current_user_id();
151 $where = $this->build_where_conditions( $user_id, $atts );
152
153 // Fetch subscriptions (deduplicated by subscription_id).
154 $subscriptions = [];
155 if ( 'true' === $atts['show_subscription'] ) {
156 $subscriptions = $this->get_user_subscriptions( $where );
157 }
158
159 // Fetch all payments for history section.
160 $current_page = isset( $_GET['srfm_page'] ) ? absint( wp_unslash( $_GET['srfm_page'] ) ) : 1; // phpcs:ignore WordPress.Security.NonceVerification.Recommended
161 if ( $current_page < 1 ) {
162 $current_page = 1;
163 }
164 $offset = ( $current_page - 1 ) * $per_page;
165
166 /** Query arguments for fetching payments. @var array<string,mixed> $query_args */
167 $query_args = [
168 'where' => $where,
169 'limit' => $per_page,
170 'offset' => $offset,
171 'orderby' => 'created_at',
172 'order' => 'DESC',
173 ];
174
175 /**
176 * Filter the query arguments before fetching payments.
177 *
178 * @since 2.8.0
179 * @param array<string,mixed> $query_args Query arguments for Payments::get_all().
180 * @param int $user_id Current user ID.
181 */
182 $query_args = apply_filters( 'srfm_payment_history_query_args', $query_args, $user_id );
183
184 $payments = Payments::get_all( $query_args );
185 $total_count = Payments::get_instance()->get_total_count( $where );
186 $total_pages = $per_page > 0 ? (int) ceil( $total_count / $per_page ) : 1;
187
188 if ( empty( $subscriptions ) && empty( $payments ) ) {
189 return $this->get_empty_message();
190 }
191
192 ob_start();
193 ?>
194 <div class="srfm-pd-widget">
195 <?php
196 if ( ! empty( $subscriptions ) ) {
197 $this->render_subscriptions_section( $subscriptions );
198 }
199
200 if ( ! empty( $payments ) ) {
201 $this->render_payments_section( $payments, $current_page, $total_pages, $total_count );
202 }
203 ?>
204 <!-- Overlay containers for JS panels -->
205 <div class="srfm-pd-overlay" id="srfm-pd-sub-overlay">
206 <div class="srfm-pd-panel" id="srfm-pd-sub-panel"></div>
207 </div>
208 <div class="srfm-pd-overlay" id="srfm-pd-tx-overlay">
209 <div class="srfm-pd-panel" id="srfm-pd-tx-panel"></div>
210 </div>
211 <div class="srfm-pd-overlay" id="srfm-pd-cancel-overlay">
212 <div class="srfm-pd-panel" id="srfm-pd-cancel-panel"></div>
213 </div>
214 </div>
215 <?php
216 $this->output_js_data( $subscriptions, $payments );
217
218 $output = ob_get_clean();
219
220 /**
221 * Filter the final payment history HTML output.
222 *
223 * @since 2.8.0
224 * @param string $output The HTML output.
225 * @param array<array<string, mixed>> $payments The payment records.
226 * @param array<string,string> $atts The shortcode attributes.
227 */
228 return apply_filters( 'srfm_payment_history_output', is_string( $output ) ? $output : '', $payments, $atts );
229 }
230
231 // =========================================================================
232 // AJAX Handlers
233 // =========================================================================
234
235 /**
236 * AJAX handler for frontend subscription cancellation.
237 *
238 * @since 2.8.0
239 * @return void
240 */
241 public function ajax_cancel_subscription() {
242 if ( ! wp_verify_nonce( sanitize_text_field( wp_unslash( $_POST['nonce'] ?? '' ) ), 'srfm_frontend_payment_nonce' ) ) {
243 wp_send_json_error( __( 'Security check failed.', 'sureforms' ) );
244 }
245
246 if ( ! is_user_logged_in() ) {
247 wp_send_json_error( __( 'You must be logged in.', 'sureforms' ) );
248 }
249
250 $payment_id = isset( $_POST['payment_id'] ) ? absint( $_POST['payment_id'] ) : 0;
251
252 if ( empty( $payment_id ) ) {
253 wp_send_json_error( __( 'Invalid payment data.', 'sureforms' ) );
254 }
255
256 $payment = Payments::get( $payment_id );
257 if ( ! $payment || ! $this->user_owns_payment( $payment, get_current_user_id() ) ) {
258 wp_send_json_error( __( 'Payment not found.', 'sureforms' ) );
259 }
260
261 $type = isset( $payment['type'] ) ? strval( $payment['type'] ) : '';
262 if ( 'subscription' !== $type || empty( $payment['subscription_id'] ) ) {
263 wp_send_json_error( __( 'This payment is not a subscription.', 'sureforms' ) );
264 }
265
266 /**
267 * Filter to process subscription cancellation. Gateways hook into this.
268 *
269 * @since 2.8.0
270 * @param array<string,mixed> $result Default result.
271 * @param array<string,mixed> $payment Payment record.
272 */
273 $result = apply_filters(
274 'srfm_process_subscription_cancellation',
275 [
276 'success' => false,
277 'message' => __( 'Cancellation not supported for this gateway.', 'sureforms' ),
278 ],
279 $payment
280 );
281
282 if ( ! empty( $result['success'] ) ) {
283 wp_send_json_success( [ 'message' => isset( $result['message'] ) && is_scalar( $result['message'] ) ? strval( $result['message'] ) : __( 'Subscription cancelled successfully.', 'sureforms' ) ] );
284 } else {
285 wp_send_json_error( isset( $result['message'] ) && is_scalar( $result['message'] ) ? strval( $result['message'] ) : __( 'Failed to cancel subscription.', 'sureforms' ) );
286 }
287 }
288
289 /**
290 * Get all translatable strings for the JS frontend.
291 *
292 * @since 2.8.0
293 * @return array<string,string>
294 */
295 private function get_i18n_strings() {
296 return [
297 /* translators: %s: subscription name */
298 'cancel_confirm_now' => __( 'Your "%s" will be cancelled immediately. You will lose access right away.', 'sureforms' ),
299 'are_you_sure' => __( 'Are you sure?', 'sureforms' ),
300 'keep_subscription' => __( 'Keep Subscription', 'sureforms' ),
301 'yes_cancel' => __( 'Yes, Cancel', 'sureforms' ),
302 'done' => __( 'Done', 'sureforms' ),
303 'subscription_cancelled' => __( 'Subscription Cancelled', 'sureforms' ),
304 'cancel_subscription' => __( 'Cancel Subscription', 'sureforms' ),
305 'back' => __( 'Back', 'sureforms' ),
306 'subscription' => __( 'Subscription', 'sureforms' ),
307 'amount' => __( 'Amount', 'sureforms' ),
308 'next_payment' => __( 'Next Payment', 'sureforms' ),
309 'cancelled_on' => __( 'Cancelled On', 'sureforms' ),
310 'access_until' => __( 'Access Until', 'sureforms' ),
311 'started' => __( 'Started', 'sureforms' ),
312 'form' => __( 'Form', 'sureforms' ),
313 'type' => __( 'Type', 'sureforms' ),
314 'gateway' => __( 'Gateway', 'sureforms' ),
315 'transaction_id' => __( 'Transaction ID', 'sureforms' ),
316 'parent_subscription' => __( 'Parent Subscription', 'sureforms' ),
317 'plan' => __( 'Plan', 'sureforms' ),
318 'status' => __( 'Status', 'sureforms' ),
319 'one_time_note' => __( 'One-time payment. No recurring subscription associated.', 'sureforms' ),
320 'subscription_payment' => __( 'Subscription Payment', 'sureforms' ),
321 'one_time_payment' => __( 'One-time Payment', 'sureforms' ),
322 'processing' => __( 'Processing...', 'sureforms' ),
323 'cancel_success' => __( 'The subscription has been cancelled successfully.', 'sureforms' ),
324 'error' => __( 'Something went wrong. Please try again.', 'sureforms' ),
325 // Status labels for JS overlay panels.
326 'status_active' => __( 'Active', 'sureforms' ),
327 'status_trialing' => __( 'Trialing', 'sureforms' ),
328 'status_canceled' => __( 'Cancelled', 'sureforms' ),
329 'status_past_due' => __( 'Past Due', 'sureforms' ),
330 'status_paused' => __( 'Paused', 'sureforms' ),
331 'status_succeeded' => __( 'Paid', 'sureforms' ),
332 'status_pending' => __( 'Pending', 'sureforms' ),
333 'status_failed' => __( 'Failed', 'sureforms' ),
334 'status_refunded' => __( 'Refunded', 'sureforms' ),
335 'status_partially_refunded' => __( 'Partially Refunded', 'sureforms' ),
336 'status_processing' => __( 'Processing', 'sureforms' ),
337 ];
338 }
339
340 // =========================================================================
341 // Subscriptions Section
342 // =========================================================================
343
344 /**
345 * Get user subscriptions, deduplicated by subscription_id.
346 *
347 * @param array<int,array<int|string,array<string,mixed>|string>> $where WHERE conditions.
348 * @since 2.8.0
349 * @return array<int,array<string,mixed>>
350 */
351 private function get_user_subscriptions( $where ) {
352 $sub_where = $where;
353 $sub_where[] = [
354 [
355 'key' => 'type',
356 'compare' => '=',
357 'value' => 'subscription',
358 ],
359 ];
360
361 // Cap subscription fetch to avoid unbounded queries. Pagination is not
362 // currently supported for the subscriptions section.
363 $all_subs = Payments::get_all(
364 [
365 'where' => $sub_where,
366 'orderby' => 'created_at',
367 'order' => 'DESC',
368 'limit' => 100,
369 ]
370 );
371
372 $unique_subs = [];
373 $seen_ids = [];
374 foreach ( $all_subs as $sub ) {
375 $sub_id = isset( $sub['subscription_id'] ) ? strval( $sub['subscription_id'] ) : '';
376 if ( empty( $sub_id ) || in_array( $sub_id, $seen_ids, true ) ) {
377 continue;
378 }
379 $seen_ids[] = $sub_id;
380 $unique_subs[] = $sub;
381 }
382
383 return $unique_subs;
384 }
385
386 /**
387 * Render the subscriptions section.
388 *
389 * @param array<int,array<string,mixed>> $subscriptions Subscription records.
390 * @since 2.8.0
391 * @return void
392 */
393 private function render_subscriptions_section( $subscriptions ) {
394 $active_count = 0;
395 $cancelled_count = 0;
396
397 foreach ( $subscriptions as $sub ) {
398 $status = isset( $sub['subscription_status'] ) && is_scalar( $sub['subscription_status'] ) ? strval( $sub['subscription_status'] ) : '';
399 if ( in_array( $status, [ 'active', 'trialing' ], true ) ) {
400 ++$active_count;
401 } elseif ( 'canceled' === $status ) {
402 ++$cancelled_count;
403 }
404 }
405
406 $count_parts = [];
407 if ( $active_count > 0 ) {
408 /* translators: %d: number of active subscriptions */
409 $count_parts[] = sprintf( _n( '%d active', '%d active', $active_count, 'sureforms' ), $active_count );
410 }
411 if ( $cancelled_count > 0 ) {
412 /* translators: %d: number of cancelled subscriptions */
413 $count_parts[] = sprintf( _n( '%d cancelled', '%d cancelled', $cancelled_count, 'sureforms' ), $cancelled_count );
414 }
415 $count_text = implode( ' · ', $count_parts );
416 ?>
417 <div class="srfm-pd-section">
418 <div class="srfm-pd-section-header">
419 <span class="srfm-pd-section-title"><?php esc_html_e( 'Subscriptions', 'sureforms' ); ?></span>
420 <?php if ( ! empty( $count_text ) ) { ?>
421 <span class="srfm-pd-section-count"><?php echo esc_html( $count_text ); ?></span>
422 <?php } ?>
423 </div>
424 <?php foreach ( $subscriptions as $index => $sub ) { ?>
425 <?php $this->render_subscription_row( $sub, $index ); ?>
426 <?php } ?>
427 </div>
428 <?php
429 }
430
431 /**
432 * Render a single subscription row.
433 *
434 * @param array<string,mixed> $sub Subscription record.
435 * @param int $index Row index.
436 * @since 2.8.0
437 * @return void
438 */
439 private function render_subscription_row( $sub, $index ) {
440 $status = isset( $sub['subscription_status'] ) && is_scalar( $sub['subscription_status'] ) ? strval( $sub['subscription_status'] ) : '';
441 $is_active = in_array( $status, [ 'active', 'trialing' ], true );
442 $is_cancelled = 'canceled' === $status;
443 $currency = isset( $sub['currency'] ) && is_string( $sub['currency'] ) ? strtoupper( $sub['currency'] ) : 'USD';
444 $form_title = $this->get_form_title( isset( $sub['form_id'] ) && is_numeric( $sub['form_id'] ) ? absint( $sub['form_id'] ) : 0 );
445 $sub_data = $this->extract_subscription_data( $sub );
446 $amount_text = $this->format_amount( isset( $sub['total_amount'] ) && is_numeric( $sub['total_amount'] ) ? floatval( $sub['total_amount'] ) : 0.0, $currency );
447
448 if ( ! empty( $sub_data['interval_label'] ) ) {
449 $amount_text .= ' / ' . $sub_data['interval_label'];
450 }
451
452 $meta_text = esc_html( $amount_text );
453 if ( $is_active && ! empty( $sub_data['next_payment'] ) ) {
454 /* translators: %s: next payment date */
455 $meta_text .= ' · ' . sprintf( esc_html__( 'Next: %s', 'sureforms' ), esc_html( $sub_data['next_payment'] ) );
456 } elseif ( $is_cancelled && ! empty( $sub_data['cancelled_on'] ) ) {
457 /* translators: %s: cancellation date */
458 $meta_text = '<span class="srfm-pd-strike">' . esc_html( $amount_text ) . '</span> · ' . sprintf( esc_html__( 'Cancelled %s', 'sureforms' ), esc_html( $sub_data['cancelled_on'] ) );
459 }
460
461 $badge_class = $is_active ? 'srfm-pd-badge--active' : ( $is_cancelled ? 'srfm-pd-badge--cancelled' : 'srfm-pd-badge--pending' );
462 $badge_label = $this->get_subscription_status_label( $status );
463 $row_class = 'srfm-pd-sub-row' . ( $is_cancelled ? ' srfm-pd-sub-row--cancelled' : '' );
464 $plan_name = ! empty( $sub_data['plan_name'] ) ? $sub_data['plan_name'] : $form_title;
465 ?>
466 <div class="<?php echo esc_attr( $row_class ); ?>" data-index="<?php echo esc_attr( strval( $index ) ); ?>" role="button" tabindex="0">
467 <div class="srfm-pd-sub-row-left">
468 <div class="srfm-pd-sub-row-name"><?php echo esc_html( $plan_name ); ?></div>
469 <div class="srfm-pd-sub-row-meta"><?php echo wp_kses_post( $meta_text ); ?></div>
470 </div>
471 <div class="srfm-pd-sub-row-right">
472 <span class="srfm-pd-badge <?php echo esc_attr( $badge_class ); ?>">
473 <span class="srfm-pd-badge-dot"></span><?php echo esc_html( $badge_label ); ?>
474 </span>
475 <span class="srfm-pd-chevron" aria-hidden="true"></span>
476 </div>
477 </div>
478 <?php
479 }
480
481 // =========================================================================
482 // Payments Section
483 // =========================================================================
484
485 /**
486 * Render the payment history section.
487 *
488 * @param array<int,array<string,mixed>> $payments Payment records.
489 * @param int $current_page Current page.
490 * @param int $total_pages Total pages.
491 * @param int $total_count Total payment count.
492 * @since 2.8.0
493 * @return void
494 */
495 private function render_payments_section( $payments, $current_page, $total_pages, $total_count ) {
496 ?>
497 <div class="srfm-pd-section">
498 <div class="srfm-pd-section-header">
499 <span class="srfm-pd-section-title"><?php esc_html_e( 'Payment History', 'sureforms' ); ?></span>
500 <span class="srfm-pd-section-count">
501 <?php
502 /* translators: %d: total number of transactions */
503 printf( esc_html( _n( '%d transaction', '%d transactions', $total_count, 'sureforms' ) ), intval( $total_count ) );
504 ?>
505 </span>
506 </div>
507 <?php foreach ( $payments as $index => $payment ) { ?>
508 <?php $this->render_payment_row( $payment, $index ); ?>
509 <?php } ?>
510
511 <?php if ( $total_pages > 1 ) { ?>
512 <div class="srfm-pd-pagination">
513 <span class="srfm-pd-pagination-info">
514 <?php
515 $start = ( ( $current_page - 1 ) * count( $payments ) ) + 1;
516 $end = $start + count( $payments ) - 1;
517 printf(
518 /* translators: 1: start number, 2: end number, 3: total number */
519 esc_html__( 'Showing %1$d–%2$d of %3$d transactions', 'sureforms' ),
520 intval( $start ),
521 intval( $end ),
522 intval( $total_count )
523 );
524 ?>
525 </span>
526 <div class="srfm-pd-pagination-links">
527 <?php if ( $current_page > 1 ) { ?>
528 <a href="<?php echo esc_url( add_query_arg( 'srfm_page', $current_page - 1 ) ); ?>" class="srfm-pd-pagination-link">
529 &laquo; <?php esc_html_e( 'Previous', 'sureforms' ); ?>
530 </a>
531 <?php } ?>
532 <?php if ( $current_page < $total_pages ) { ?>
533 <a href="<?php echo esc_url( add_query_arg( 'srfm_page', $current_page + 1 ) ); ?>" class="srfm-pd-pagination-link">
534 <?php esc_html_e( 'Next', 'sureforms' ); ?> &raquo;
535 </a>
536 <?php } ?>
537 </div>
538 </div>
539 <?php } ?>
540 </div>
541 <?php
542 }
543
544 /**
545 * Render a single payment row.
546 *
547 * @param array<string,mixed> $payment Payment record.
548 * @param int $index Row index.
549 * @since 2.8.0
550 * @return void
551 */
552 private function render_payment_row( $payment, $index ) {
553 $currency = isset( $payment['currency'] ) && is_string( $payment['currency'] ) ? strtoupper( $payment['currency'] ) : 'USD';
554 $status = isset( $payment['status'] ) && is_scalar( $payment['status'] ) ? strval( $payment['status'] ) : 'pending';
555 $txn_id = ! empty( $payment['srfm_txn_id'] ) && is_scalar( $payment['srfm_txn_id'] ) ? strval( $payment['srfm_txn_id'] ) : '';
556 $form_title = $this->get_form_title( isset( $payment['form_id'] ) && is_numeric( $payment['form_id'] ) ? absint( $payment['form_id'] ) : 0 );
557 $date_format = is_string( get_option( 'date_format' ) ) ? get_option( 'date_format' ) : 'Y-m-d';
558 $date = isset( $payment['created_at'] ) && is_string( $payment['created_at'] )
559 ? date_i18n( $date_format, strtotime( $payment['created_at'] ) )
560 : '';
561
562 $badge_class = $this->get_payment_badge_class( $status );
563 $badge_label = $this->get_payment_status_label( $status );
564 ?>
565 <div class="srfm-pd-pay-row" data-index="<?php echo esc_attr( strval( $index ) ); ?>" role="button" tabindex="0">
566 <div class="srfm-pd-pay-row-left">
567 <div class="srfm-pd-pay-row-form"><?php echo esc_html( $form_title ); ?></div>
568 <div class="srfm-pd-pay-row-id"><?php echo esc_html( $txn_id . ( $txn_id ? ' · ' : '' ) . $date ); ?></div>
569 </div>
570 <div class="srfm-pd-pay-row-right">
571 <span class="srfm-pd-badge <?php echo esc_attr( $badge_class ); ?>">
572 <span class="srfm-pd-badge-dot"></span><?php echo esc_html( $badge_label ); ?>
573 </span>
574 <span class="srfm-pd-pay-row-amount"><?php echo esc_html( $this->format_amount( isset( $payment['total_amount'] ) && is_numeric( $payment['total_amount'] ) ? floatval( $payment['total_amount'] ) : 0.0, $currency ) ); ?></span>
575 <span class="srfm-pd-chevron" aria-hidden="true"></span>
576 </div>
577 </div>
578 <?php
579 }
580
581 // =========================================================================
582 // JS Data Output
583 // =========================================================================
584
585 /**
586 * Output subscription and payment data as inline JSON for JS overlays.
587 *
588 * @param array<int,array<string,mixed>> $subscriptions Subscription records.
589 * @param array<int,array<string,mixed>> $payments Payment records.
590 * @since 2.8.0
591 * @return void
592 */
593 private function output_js_data( $subscriptions, $payments ) {
594 $date_format_opt = get_option( 'date_format' );
595 $date_format = is_string( $date_format_opt ) ? $date_format_opt : 'Y-m-d';
596 $subs_data = [];
597
598 foreach ( $subscriptions as $sub ) {
599 $currency = isset( $sub['currency'] ) && is_string( $sub['currency'] ) ? strtoupper( $sub['currency'] ) : 'USD';
600 $form_title = $this->get_form_title( isset( $sub['form_id'] ) && is_numeric( $sub['form_id'] ) ? absint( $sub['form_id'] ) : 0 );
601 $sub_info = $this->extract_subscription_data( $sub );
602 $status = isset( $sub['subscription_status'] ) && is_scalar( $sub['subscription_status'] ) ? strval( $sub['subscription_status'] ) : '';
603 $is_active = in_array( $status, [ 'active', 'trialing' ], true );
604
605 $amount_display = $this->format_amount( isset( $sub['total_amount'] ) && is_numeric( $sub['total_amount'] ) ? floatval( $sub['total_amount'] ) : 0.0, $currency );
606 if ( ! empty( $sub_info['interval_label'] ) ) {
607 $amount_display .= ' / ' . $sub_info['interval_label'];
608 }
609
610 $sub_id = isset( $sub['id'] ) && is_numeric( $sub['id'] ) ? $sub['id'] : 0;
611 $subs_data[] = [
612 'id' => absint( $sub_id ),
613 'name' => ! empty( $sub_info['plan_name'] ) ? $sub_info['plan_name'] : $form_title,
614 'form' => $form_title,
615 'amount' => $amount_display,
616 'next' => $sub_info['next_payment'],
617 'gateway' => $this->format_gateway_label( isset( $sub['gateway'] ) && is_scalar( $sub['gateway'] ) ? strval( $sub['gateway'] ) : '' ),
618 'started' => isset( $sub['created_at'] ) && is_string( $sub['created_at'] )
619 ? date_i18n( $date_format, strtotime( $sub['created_at'] ) ) : '',
620 'status' => $status,
621 'cancelledOn' => $sub_info['cancelled_on'],
622 'accessUntil' => $sub_info['access_until'],
623 'canCancel' => $is_active,
624 'subscriptionId' => isset( $sub['subscription_id'] ) && is_scalar( $sub['subscription_id'] ) ? strval( $sub['subscription_id'] ) : '',
625 'paymentId' => absint( $sub_id ),
626 ];
627 }
628
629 $txs_data = [];
630 foreach ( $payments as $payment ) {
631 $currency = isset( $payment['currency'] ) && is_string( $payment['currency'] ) ? strtoupper( $payment['currency'] ) : 'USD';
632 $status = isset( $payment['status'] ) && is_scalar( $payment['status'] ) ? strval( $payment['status'] ) : 'pending';
633 $type = isset( $payment['type'] ) && is_scalar( $payment['type'] ) ? strval( $payment['type'] ) : 'payment';
634 $form_title = $this->get_form_title( isset( $payment['form_id'] ) && is_numeric( $payment['form_id'] ) ? absint( $payment['form_id'] ) : 0 );
635
636 $payment_id = isset( $payment['id'] ) && is_numeric( $payment['id'] ) ? $payment['id'] : 0;
637 $tx_item = [
638 'id' => ! empty( $payment['srfm_txn_id'] ) && is_scalar( $payment['srfm_txn_id'] ) ? strval( $payment['srfm_txn_id'] ) : 'SF-' . absint( $payment_id ),
639 'paymentId' => absint( $payment_id ),
640 'form' => $form_title,
641 'date' => isset( $payment['created_at'] ) && is_string( $payment['created_at'] )
642 ? date_i18n( $date_format, strtotime( $payment['created_at'] ) ) : '',
643 'amount' => $this->format_amount( isset( $payment['total_amount'] ) && is_numeric( $payment['total_amount'] ) ? floatval( $payment['total_amount'] ) : 0.0, $currency ),
644 'status' => $status,
645 'type' => in_array( $type, [ 'subscription', 'renewal' ], true ) ? 'subscription' : 'single',
646 'gateway' => $this->format_gateway_label( isset( $payment['gateway'] ) && is_scalar( $payment['gateway'] ) ? strval( $payment['gateway'] ) : '' ),
647 'txn' => isset( $payment['transaction_id'] ) && is_scalar( $payment['transaction_id'] ) ? strval( $payment['transaction_id'] ) : '',
648 ];
649
650 if ( in_array( $type, [ 'subscription', 'renewal' ], true ) && ! empty( $payment['subscription_id'] ) ) {
651 $sub_info = $this->extract_subscription_data( $payment );
652 $amount_display = $this->format_amount( isset( $payment['total_amount'] ) && is_numeric( $payment['total_amount'] ) ? floatval( $payment['total_amount'] ) : 0.0, $currency );
653 if ( ! empty( $sub_info['interval_label'] ) ) {
654 $amount_display .= ' / ' . $sub_info['interval_label'];
655 }
656
657 $tx_item['sub'] = [
658 'name' => ! empty( $sub_info['plan_name'] ) ? $sub_info['plan_name'] : $form_title,
659 'interval' => $amount_display,
660 'next' => $sub_info['next_payment'],
661 'status' => $this->get_subscription_status_label( isset( $payment['subscription_status'] ) && is_scalar( $payment['subscription_status'] ) ? strval( $payment['subscription_status'] ) : '' ),
662 ];
663 }
664
665 $txs_data[] = $tx_item;
666 }
667 $inline_data = sprintf(
668 'window.srfmDashboardSubs=%s;window.srfmDashboardTxs=%s;',
669 wp_json_encode( $subs_data, JSON_HEX_TAG | JSON_HEX_AMP ),
670 wp_json_encode( $txs_data, JSON_HEX_TAG | JSON_HEX_AMP )
671 );
672 wp_add_inline_script( 'srfm-payment-history', $inline_data, 'before' );
673 }
674
675 // =========================================================================
676 // Data Extraction Helpers
677 // =========================================================================
678
679 /**
680 * Extract subscription-specific data from a payment record.
681 *
682 * @param array<string,mixed> $payment Payment record.
683 * @since 2.8.0
684 * @return array{plan_name:string,interval_label:string,next_payment:string,cancelled_on:string,access_until:string}
685 */
686 private function extract_subscription_data( $payment ) {
687 $data = [
688 'plan_name' => '',
689 'interval_label' => '',
690 'next_payment' => '',
691 'cancelled_on' => '',
692 'access_until' => '',
693 ];
694
695 $payment_data = $this->parse_json_field( $payment['payment_data'] ?? '' );
696 $extra = $this->parse_json_field( $payment['extra'] ?? '' );
697
698 $data['plan_name'] = $this->get_string_from_sources( 'plan_name', $payment_data, $extra );
699
700 $interval = $this->get_string_from_sources( 'interval', $payment_data, $extra );
701 $interval_count = $this->get_string_from_sources( 'interval_count', $payment_data, $extra );
702 if ( ! empty( $interval ) ) {
703 $data['interval_label'] = $this->format_interval( $interval, intval( $interval_count ? $interval_count : '1' ) );
704 }
705
706 $next_date = $this->get_string_from_sources( 'current_period_end', $payment_data, $extra );
707 if ( ! empty( $next_date ) ) {
708 $data['next_payment'] = $this->format_timestamp( $next_date );
709 }
710
711 $cancelled_at = $this->get_string_from_sources( 'canceled_at', $payment_data, $extra );
712 if ( ! empty( $cancelled_at ) ) {
713 $data['cancelled_on'] = $this->format_timestamp( $cancelled_at );
714 } elseif ( isset( $payment['updated_at'] ) && is_string( $payment['updated_at'] ) && 'canceled' === ( $payment['subscription_status'] ?? '' ) ) {
715 $date_fmt = get_option( 'date_format' );
716 $data['cancelled_on'] = date_i18n( is_string( $date_fmt ) ? $date_fmt : 'Y-m-d', strtotime( $payment['updated_at'] ) );
717 }
718
719 if ( ! empty( $next_date ) && 'canceled' === ( $payment['subscription_status'] ?? '' ) ) {
720 $data['access_until'] = $this->format_timestamp( $next_date );
721 }
722
723 return $data;
724 }
725
726 /**
727 * Parse a JSON field that may be a string or already an array.
728 *
729 * @param mixed $value Field value.
730 * @since 2.8.0
731 * @return array<string,mixed>
732 */
733 private function parse_json_field( $value ) {
734 if ( is_array( $value ) ) {
735 return $value;
736 }
737 if ( is_string( $value ) && ! empty( $value ) ) {
738 $decoded = json_decode( $value, true );
739 return is_array( $decoded ) ? $decoded : [];
740 }
741 return [];
742 }
743
744 /**
745 * Get a string value from two data source arrays.
746 *
747 * @param string $key Key to look for.
748 * @param array<string,mixed> $data1 Primary data source.
749 * @param array<string,mixed> $data2 Fallback data source.
750 * @since 2.8.0
751 * @return string
752 */
753 private function get_string_from_sources( $key, $data1, $data2 ) {
754 if ( ! empty( $data1[ $key ] ) && is_scalar( $data1[ $key ] ) ) {
755 return strval( $data1[ $key ] );
756 }
757 if ( ! empty( $data2[ $key ] ) && is_scalar( $data2[ $key ] ) ) {
758 return strval( $data2[ $key ] );
759 }
760 return '';
761 }
762
763 /**
764 * Format a timestamp (numeric or date string) to a localized date.
765 *
766 * @param string $value Timestamp or date string.
767 * @since 2.8.0
768 * @return string Formatted date.
769 */
770 private function format_timestamp( $value ) {
771 $date_opt = get_option( 'date_format' );
772 $format = is_string( $date_opt ) ? $date_opt : 'Y-m-d';
773 return is_numeric( $value )
774 ? date_i18n( $format, intval( $value ) )
775 : date_i18n( $format, strtotime( $value ) );
776 }
777
778 /**
779 * Get the form title from form_id.
780 *
781 * @param int $form_id Form post ID.
782 * @since 2.8.0
783 * @return string Form title.
784 */
785 private function get_form_title( $form_id ) {
786 static $cache = [];
787
788 if ( $form_id <= 0 ) {
789 return __( 'Unknown Form', 'sureforms' );
790 }
791
792 if ( isset( $cache[ $form_id ] ) ) {
793 return $cache[ $form_id ];
794 }
795 $title = get_the_title( $form_id );
796 $cache[ $form_id ] = ! empty( $title ) ? $title : __( 'Unknown Form', 'sureforms' );
797 return $cache[ $form_id ];
798 }
799
800 // =========================================================================
801 // Formatting Helpers
802 // =========================================================================
803
804 /**
805 * Format a payment amount with currency symbol.
806 *
807 * @param float $amount Payment amount.
808 * @param string $currency Currency code.
809 * @since 2.8.0
810 * @return string Formatted amount.
811 */
812 private function format_amount( $amount, $currency ) {
813 $symbol = Payment_Helper::get_currency_symbol( $currency );
814 $position = Payment_Helper::get_currency_sign_position();
815
816 $formatted = Payment_Helper::is_zero_decimal_currency( $currency )
817 ? number_format( $amount, 0 )
818 : number_format( $amount, 2 );
819
820 switch ( $position ) {
821 case 'right':
822 return $formatted . $symbol;
823 case 'left_space':
824 return $symbol . ' ' . $formatted;
825 case 'right_space':
826 return $formatted . ' ' . $symbol;
827 case 'left':
828 default:
829 return $symbol . $formatted;
830 }
831 }
832
833 /**
834 * Format subscription interval to human-readable label.
835 *
836 * @param string $interval Interval type (day, week, month, year).
837 * @param int $interval_count Interval count.
838 * @since 2.8.0
839 * @return string
840 */
841 private function format_interval( $interval, $interval_count = 1 ) {
842 $labels = [
843 'day' => _x( 'day', 'billing interval', 'sureforms' ),
844 'week' => _x( 'wk', 'billing interval', 'sureforms' ),
845 'month' => _x( 'mo', 'billing interval', 'sureforms' ),
846 'year' => _x( 'yr', 'billing interval', 'sureforms' ),
847 ];
848
849 $label = $labels[ $interval ] ?? $interval;
850 return $interval_count > 1 ? $interval_count . ' ' . $label : $label;
851 }
852
853 /**
854 * Format a gateway identifier into a display label.
855 *
856 * @param string $gateway Gateway identifier (e.g., 'stripe', 'paypal').
857 * @since 2.8.0
858 * @return string Display label.
859 */
860 private function format_gateway_label( $gateway ) {
861 $labels = [
862 'stripe' => 'Stripe',
863 'paypal' => 'PayPal',
864 ];
865
866 /**
867 * Filter the gateway display labels map.
868 *
869 * @since 2.8.0
870 * @param array<string,string> $labels Gateway ID to display label map.
871 */
872 $labels = apply_filters( 'srfm_payment_history_gateway_labels', $labels );
873
874 return $labels[ $gateway ] ?? ucfirst( $gateway );
875 }
876
877 /**
878 * Get subscription status label.
879 *
880 * @param string $status Subscription status.
881 * @since 2.8.0
882 * @return string
883 */
884 private function get_subscription_status_label( $status ) {
885 $labels = [
886 'active' => __( 'Active', 'sureforms' ),
887 'trialing' => __( 'Trialing', 'sureforms' ),
888 'canceled' => __( 'Cancelled', 'sureforms' ),
889 'past_due' => __( 'Past Due', 'sureforms' ),
890 'paused' => __( 'Paused', 'sureforms' ),
891 ];
892 return $labels[ $status ] ?? ucfirst( str_replace( '_', ' ', $status ) );
893 }
894
895 /**
896 * Get payment status label.
897 *
898 * @param string $status Payment status.
899 * @since 2.8.0
900 * @return string
901 */
902 private function get_payment_status_label( $status ) {
903 $labels = [
904 'succeeded' => __( 'Paid', 'sureforms' ),
905 'pending' => __( 'Pending', 'sureforms' ),
906 'failed' => __( 'Failed', 'sureforms' ),
907 'canceled' => __( 'Cancelled', 'sureforms' ),
908 'refunded' => __( 'Refunded', 'sureforms' ),
909 'partially_refunded' => __( 'Partially Refunded', 'sureforms' ),
910 'processing' => __( 'Processing', 'sureforms' ),
911 'active' => __( 'Active', 'sureforms' ),
912 ];
913 return $labels[ $status ] ?? ucfirst( str_replace( '_', ' ', $status ) );
914 }
915
916 /**
917 * Get payment status badge CSS class.
918 *
919 * @param string $status Payment status.
920 * @since 2.8.0
921 * @return string
922 */
923 private function get_payment_badge_class( $status ) {
924 $map = [
925 'succeeded' => 'srfm-pd-badge--paid',
926 'active' => 'srfm-pd-badge--paid',
927 'pending' => 'srfm-pd-badge--pending',
928 'processing' => 'srfm-pd-badge--pending',
929 'failed' => 'srfm-pd-badge--cancelled',
930 'canceled' => 'srfm-pd-badge--cancelled',
931 'refunded' => 'srfm-pd-badge--refunded',
932 'partially_refunded' => 'srfm-pd-badge--refunded',
933 ];
934 return $map[ $status ] ?? 'srfm-pd-badge--pending';
935 }
936
937 // =========================================================================
938 // Query Helpers
939 // =========================================================================
940
941 /**
942 * Build WHERE conditions for the payment query.
943 *
944 * @param int $user_id WordPress user ID.
945 * @param array<string,string> $atts Shortcode attributes.
946 * @since 2.8.0
947 * @return array<int,array<int|string,array<string,mixed>|string>> WHERE conditions.
948 */
949 private function build_where_conditions( $user_id, $atts ) {
950 $stripe_customer_id = get_user_meta( $user_id, 'srfm_stripe_customer_id', true );
951 $or_conditions = [];
952
953 if ( ! empty( $stripe_customer_id ) && is_string( $stripe_customer_id ) ) {
954 $or_conditions[] = [
955 'key' => 'customer_id',
956 'compare' => '=',
957 'value' => $stripe_customer_id,
958 ];
959 }
960
961 $where = [];
962 if ( ! empty( $or_conditions ) ) {
963 $or_conditions['RELATION'] = 'OR';
964 $where[] = $or_conditions;
965 } else {
966 // No customer ID found — return a zero-result condition to prevent data leakage.
967 // Pro gateways (e.g., PayPal) may add their own customer_id conditions via the filter below.
968 $where[] = [
969 [
970 'key' => 'customer_id',
971 'compare' => '=',
972 'value' => 'no_customer_' . $user_id,
973 ],
974 'RELATION' => 'OR',
975 ];
976 }
977
978 /**
979 * Filter the supported payment gateways for payment history.
980 *
981 * Free plugin defaults to ['stripe']. Pro can add additional gateways
982 * (e.g., 'paypal') by hooking into this filter.
983 *
984 * @since 2.8.0
985 * @param array<string> $gateways Array of supported gateway identifiers.
986 */
987 $supported_gateways = apply_filters( 'srfm_payment_history_supported_gateways', [ 'stripe' ] );
988 $supported_gateways = array_map( 'sanitize_text_field', $supported_gateways );
989 $supported_gateways = array_filter( $supported_gateways );
990
991 if ( ! empty( $supported_gateways ) ) {
992 if ( 1 === count( $supported_gateways ) ) {
993 $where[] = [
994 [
995 'key' => 'gateway',
996 'compare' => '=',
997 'value' => reset( $supported_gateways ),
998 ],
999 ];
1000 } else {
1001 $where[] = [
1002 [
1003 'key' => 'gateway',
1004 'compare' => 'IN',
1005 'value' => array_values( $supported_gateways ),
1006 ],
1007 ];
1008 }
1009 }
1010
1011 /**
1012 * Filter the WHERE conditions for the payment history query.
1013 *
1014 * @since 2.8.0
1015 * @param array<int,array<int|string,array<string,mixed>|string>> $where WHERE conditions array.
1016 * @param int $user_id WordPress user ID.
1017 * @param array<string,string> $atts Shortcode attributes.
1018 */
1019 return apply_filters( 'srfm_payment_history_where_conditions', $where, $user_id, $atts );
1020 }
1021
1022 /**
1023 * Check if the current user owns the payment record.
1024 *
1025 * @param array<string,mixed> $payment Payment record.
1026 * @param int $user_id WordPress user ID.
1027 * @since 2.8.0
1028 * @return bool
1029 */
1030 private function user_owns_payment( $payment, $user_id ) {
1031 $stripe_customer_id = get_user_meta( $user_id, 'srfm_stripe_customer_id', true );
1032
1033 // Only use system-assigned customer_id for ownership — email matching is not safe
1034 // for destructive actions (e.g., cancellation) because WP account email is user-controlled.
1035 if ( ! empty( $stripe_customer_id ) && ! empty( $payment['customer_id'] ) && $stripe_customer_id === $payment['customer_id'] ) {
1036 return true;
1037 }
1038
1039 /**
1040 * Filter whether the user owns the payment.
1041 *
1042 * @since 2.8.0
1043 * @param bool $owns Whether the user owns the payment.
1044 * @param array<string,mixed> $payment Payment record.
1045 * @param int $user_id WordPress user ID.
1046 */
1047 return (bool) apply_filters( 'srfm_payment_history_user_owns_payment', false, $payment, $user_id );
1048 }
1049
1050 // =========================================================================
1051 // Messages
1052 // =========================================================================
1053
1054 /**
1055 * Get the login required message.
1056 *
1057 * @since 2.8.0
1058 * @return string HTML login message.
1059 */
1060 private function get_login_message() {
1061 $login_url = wp_login_url( (string) get_permalink() );
1062 $html = sprintf(
1063 '<div class="srfm-pd-widget"><div class="srfm-pd-message">%s <a href="%s">%s</a> %s</div></div>',
1064 esc_html__( 'Please', 'sureforms' ),
1065 esc_url( $login_url ),
1066 esc_html__( 'log in', 'sureforms' ),
1067 esc_html__( 'to view your payment dashboard.', 'sureforms' )
1068 );
1069
1070 /**
1071 * Filter the login required message HTML.
1072 *
1073 * @since 2.8.0
1074 */
1075 return apply_filters( 'srfm_payment_history_login_message', $html );
1076 }
1077
1078 /**
1079 * Get the no payments found message.
1080 *
1081 * @since 2.8.0
1082 * @return string HTML empty message.
1083 */
1084 private function get_empty_message() {
1085 $html = sprintf(
1086 '<div class="srfm-pd-widget"><div class="srfm-pd-message">%s</div></div>',
1087 esc_html__( 'No payments found.', 'sureforms' )
1088 );
1089
1090 /**
1091 * Filter the no payments found message HTML.
1092 *
1093 * @since 2.8.0
1094 */
1095 return apply_filters( 'srfm_payment_history_empty_message', $html );
1096 }
1097 }
1098