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