| Server IP : 146.59.209.152 / Your IP : 216.73.216.152 Web Server : Apache System : Linux webm009.cluster131.gra.hosting.ovh.net 6.18.42-ovh-vps-grsec-zfs+ #1 SMP PREEMPT_DYNAMIC Wed Aug 5 15:59:48 CEST 2026 x86_64 User : monpetu ( 144298) PHP Version : 7.4.33 Disable Function : _dyuweyrj4,_dyuweyrj4r,dl MySQL : OFF | cURL : ON | WGET : ON | Perl : ON | Python : ON | Sudo : OFF | Pkexec : OFF Directory : /home/monpetu/www/ipprint/wp-content/plugins/sureforms/inc/payments/ |
Upload File : |
<?php
/**
* Payment History Shortcode.
*
* Renders a modern payment dashboard with subscriptions section, payment history,
* and overlay detail panels for logged-in users.
*
* @package sureforms
* @since 2.8.0
*/
namespace SRFM\Inc\Payments;
use SRFM\Inc\Database\Tables\Payments;
use SRFM\Inc\Traits\Get_Instance;
if ( ! defined( 'ABSPATH' ) ) {
exit; // Exit if accessed directly.
}
/**
* Payment History Shortcode class.
*
* @since 2.8.0
*/
class Payment_History_Shortcode {
use Get_Instance;
/**
* Shortcode tag.
*/
public const SHORTCODE_TAG = 'srfm_payment_history';
/**
* Constructor.
*
* @since 2.8.0
*/
public function __construct() {
add_shortcode( self::SHORTCODE_TAG, [ $this, 'render' ] );
add_action( 'wp_enqueue_scripts', [ $this, 'enqueue_assets' ] );
// Frontend AJAX handlers.
add_action( 'wp_ajax_srfm_frontend_cancel_subscription', [ $this, 'ajax_cancel_subscription' ] );
}
/**
* Conditionally enqueue assets when the shortcode is present on the page.
*
* Also called at render time (shortcode/block callback) to support
* FSE themes and page builders where global $post is unavailable.
*
* CSS is always enqueued during wp_enqueue_scripts (lightweight, prevents FOUC
* on page builders like Elementor/Bricks that store content in postmeta).
* JS + localized data are only enqueued when the shortcode/block is detected.
*
* @since 2.8.0
* @return void
*/
public function enqueue_assets() {
$file_prefix = defined( 'SRFM_DEBUG' ) && SRFM_DEBUG ? '' : '.min';
$dir_name = defined( 'SRFM_DEBUG' ) && SRFM_DEBUG ? 'unminified' : 'minified';
// Always enqueue CSS during wp_enqueue_scripts to ensure it loads in <head>.
// WordPress silently ignores late-enqueued styles (after wp_head), so page builders
// like Elementor, Bricks, and Beaver Builder (which store content in postmeta, not
// post_content) would get no CSS at all if we only enqueued conditionally.
// The CSS file is lightweight — one small stylesheet on frontend pages is acceptable.
if ( doing_action( 'wp_enqueue_scripts' ) && ! wp_style_is( 'srfm-payment-history', 'enqueued' ) ) {
wp_enqueue_style(
'srfm-payment-history',
SRFM_URL . 'assets/css/' . $dir_name . '/payment-history' . $file_prefix . '.css',
[],
SRFM_VER
);
}
// JS + localized data: only enqueue when the shortcode/block is actually present.
// JS can be late-enqueued (footer scripts) but CSS cannot, hence the split above.
if ( wp_script_is( 'srfm-payment-history', 'enqueued' ) ) {
return;
}
// When called from the wp_enqueue_scripts hook, check if the shortcode/block is present.
// When called from render(), we know it's needed — skip the check.
if ( doing_action( 'wp_enqueue_scripts' ) ) {
global $post;
if ( ! $post instanceof \WP_Post ) {
return;
}
$has_shortcode = has_shortcode( $post->post_content, self::SHORTCODE_TAG );
$has_block = has_block( 'srfm/payment-history', $post );
if ( ! $has_shortcode && ! $has_block ) {
return;
}
}
wp_enqueue_script(
'srfm-payment-history',
SRFM_URL . 'assets/js/payment-history.js',
[],
SRFM_VER,
true
);
wp_localize_script(
'srfm-payment-history',
'srfm_payment_history',
[
'ajax_url' => admin_url( 'admin-ajax.php' ),
'nonce' => wp_create_nonce( 'srfm_frontend_payment_nonce' ),
'i18n' => $this->get_i18n_strings(),
]
);
}
/**
* Render the payment history shortcode.
*
* @param array<string,string>|string $atts Shortcode attributes.
* @since 2.8.0
* @return string HTML output.
*/
public function render( $atts ) {
$atts = shortcode_atts(
[
'per_page' => '10',
'show_subscription' => 'true',
],
is_array( $atts ) ? $atts : [],
self::SHORTCODE_TAG
);
$per_page = absint( $atts['per_page'] );
if ( $per_page <= 0 ) {
$per_page = 10;
}
if ( ! is_user_logged_in() ) {
return $this->get_login_message();
}
// Enqueue assets at render time — handles FSE themes, Elementor, and
// other page builders where global $post is unavailable during wp_enqueue_scripts.
$this->enqueue_assets();
$user_id = get_current_user_id();
$where = $this->build_where_conditions( $user_id, $atts );
// Fetch subscriptions (deduplicated by subscription_id).
$subscriptions = [];
if ( 'true' === $atts['show_subscription'] ) {
$subscriptions = $this->get_user_subscriptions( $where );
}
// Fetch all payments for history section.
$current_page = isset( $_GET['srfm_page'] ) ? absint( wp_unslash( $_GET['srfm_page'] ) ) : 1; // phpcs:ignore WordPress.Security.NonceVerification.Recommended
if ( $current_page < 1 ) {
$current_page = 1;
}
$offset = ( $current_page - 1 ) * $per_page;
/** Query arguments for fetching payments. @var array<string,mixed> $query_args */
$query_args = [
'where' => $where,
'limit' => $per_page,
'offset' => $offset,
'orderby' => 'created_at',
'order' => 'DESC',
];
/**
* Filter the query arguments before fetching payments.
*
* @since 2.8.0
* @param array<string,mixed> $query_args Query arguments for Payments::get_all().
* @param int $user_id Current user ID.
*/
$query_args = apply_filters( 'srfm_payment_history_query_args', $query_args, $user_id );
$payments = Payments::get_all( $query_args );
$total_count = Payments::get_instance()->get_total_count( $where );
$total_pages = $per_page > 0 ? (int) ceil( $total_count / $per_page ) : 1;
if ( empty( $subscriptions ) && empty( $payments ) ) {
return $this->get_empty_message();
}
ob_start();
?>
<div class="srfm-pd-widget">
<?php
if ( ! empty( $subscriptions ) ) {
$this->render_subscriptions_section( $subscriptions );
}
if ( ! empty( $payments ) ) {
$this->render_payments_section( $payments, $current_page, $total_pages, $total_count );
}
?>
<!-- Overlay containers for JS panels -->
<div class="srfm-pd-overlay" id="srfm-pd-sub-overlay">
<div class="srfm-pd-panel" id="srfm-pd-sub-panel"></div>
</div>
<div class="srfm-pd-overlay" id="srfm-pd-tx-overlay">
<div class="srfm-pd-panel" id="srfm-pd-tx-panel"></div>
</div>
<div class="srfm-pd-overlay" id="srfm-pd-cancel-overlay">
<div class="srfm-pd-panel" id="srfm-pd-cancel-panel"></div>
</div>
</div>
<?php
$this->output_js_data( $subscriptions, $payments );
$output = ob_get_clean();
/**
* Filter the final payment history HTML output.
*
* @since 2.8.0
* @param string $output The HTML output.
* @param array<array<string, mixed>> $payments The payment records.
* @param array<string,string> $atts The shortcode attributes.
*/
return apply_filters( 'srfm_payment_history_output', is_string( $output ) ? $output : '', $payments, $atts );
}
// =========================================================================
// AJAX Handlers
// =========================================================================
/**
* AJAX handler for frontend subscription cancellation.
*
* @since 2.8.0
* @return void
*/
public function ajax_cancel_subscription() {
if ( ! wp_verify_nonce( sanitize_text_field( wp_unslash( $_POST['nonce'] ?? '' ) ), 'srfm_frontend_payment_nonce' ) ) {
wp_send_json_error( __( 'Security check failed.', 'sureforms' ) );
}
if ( ! is_user_logged_in() ) {
wp_send_json_error( __( 'You must be logged in.', 'sureforms' ) );
}
$payment_id = isset( $_POST['payment_id'] ) ? absint( $_POST['payment_id'] ) : 0;
if ( empty( $payment_id ) ) {
wp_send_json_error( __( 'Invalid payment data.', 'sureforms' ) );
}
$payment = Payments::get( $payment_id );
if ( ! $payment || ! $this->user_owns_payment( $payment, get_current_user_id() ) ) {
wp_send_json_error( __( 'Payment not found.', 'sureforms' ) );
}
$type = isset( $payment['type'] ) ? strval( $payment['type'] ) : '';
if ( 'subscription' !== $type || empty( $payment['subscription_id'] ) ) {
wp_send_json_error( __( 'This payment is not a subscription.', 'sureforms' ) );
}
/**
* Filter to process subscription cancellation. Gateways hook into this.
*
* @since 2.8.0
* @param array<string,mixed> $result Default result.
* @param array<string,mixed> $payment Payment record.
*/
$result = apply_filters(
'srfm_process_subscription_cancellation',
[
'success' => false,
'message' => __( 'Cancellation not supported for this gateway.', 'sureforms' ),
],
$payment
);
if ( ! empty( $result['success'] ) ) {
wp_send_json_success( [ 'message' => isset( $result['message'] ) && is_scalar( $result['message'] ) ? strval( $result['message'] ) : __( 'Subscription cancelled successfully.', 'sureforms' ) ] );
} else {
wp_send_json_error( isset( $result['message'] ) && is_scalar( $result['message'] ) ? strval( $result['message'] ) : __( 'Failed to cancel subscription.', 'sureforms' ) );
}
}
/**
* Get all translatable strings for the JS frontend.
*
* @since 2.8.0
* @return array<string,string>
*/
private function get_i18n_strings() {
return [
/* translators: %s: subscription name */
'cancel_confirm_now' => __( 'Your "%s" will be cancelled immediately. You will lose access right away.', 'sureforms' ),
'are_you_sure' => __( 'Are you sure?', 'sureforms' ),
'keep_subscription' => __( 'Keep Subscription', 'sureforms' ),
'yes_cancel' => __( 'Yes, Cancel', 'sureforms' ),
'done' => __( 'Done', 'sureforms' ),
'subscription_cancelled' => __( 'Subscription Cancelled', 'sureforms' ),
'cancel_subscription' => __( 'Cancel Subscription', 'sureforms' ),
'back' => __( 'Back', 'sureforms' ),
'subscription' => __( 'Subscription', 'sureforms' ),
'amount' => __( 'Amount', 'sureforms' ),
'next_payment' => __( 'Next Payment', 'sureforms' ),
'cancelled_on' => __( 'Cancelled On', 'sureforms' ),
'access_until' => __( 'Access Until', 'sureforms' ),
'started' => __( 'Started', 'sureforms' ),
'form' => __( 'Form', 'sureforms' ),
'type' => __( 'Type', 'sureforms' ),
'gateway' => __( 'Gateway', 'sureforms' ),
'transaction_id' => __( 'Transaction ID', 'sureforms' ),
'parent_subscription' => __( 'Parent Subscription', 'sureforms' ),
'plan' => __( 'Plan', 'sureforms' ),
'status' => __( 'Status', 'sureforms' ),
'one_time_note' => __( 'One-time payment. No recurring subscription associated.', 'sureforms' ),
'subscription_payment' => __( 'Subscription Payment', 'sureforms' ),
'one_time_payment' => __( 'One-time Payment', 'sureforms' ),
'processing' => __( 'Processing...', 'sureforms' ),
'cancel_success' => __( 'The subscription has been cancelled successfully.', 'sureforms' ),
'error' => __( 'Something went wrong. Please try again.', 'sureforms' ),
// Status labels for JS overlay panels.
'status_active' => __( 'Active', 'sureforms' ),
'status_trialing' => __( 'Trialing', 'sureforms' ),
'status_canceled' => __( 'Cancelled', 'sureforms' ),
'status_past_due' => __( 'Past Due', 'sureforms' ),
'status_paused' => __( 'Paused', 'sureforms' ),
'status_succeeded' => __( 'Paid', 'sureforms' ),
'status_pending' => __( 'Pending', 'sureforms' ),
'status_failed' => __( 'Failed', 'sureforms' ),
'status_refunded' => __( 'Refunded', 'sureforms' ),
'status_partially_refunded' => __( 'Partially Refunded', 'sureforms' ),
'status_processing' => __( 'Processing', 'sureforms' ),
];
}
// =========================================================================
// Subscriptions Section
// =========================================================================
/**
* Get user subscriptions, deduplicated by subscription_id.
*
* @param array<int,array<int|string,array<string,mixed>|string>> $where WHERE conditions.
* @since 2.8.0
* @return array<int,array<string,mixed>>
*/
private function get_user_subscriptions( $where ) {
$sub_where = $where;
$sub_where[] = [
[
'key' => 'type',
'compare' => '=',
'value' => 'subscription',
],
];
// Cap subscription fetch to avoid unbounded queries. Pagination is not
// currently supported for the subscriptions section.
$all_subs = Payments::get_all(
[
'where' => $sub_where,
'orderby' => 'created_at',
'order' => 'DESC',
'limit' => 100,
]
);
$unique_subs = [];
$seen_ids = [];
foreach ( $all_subs as $sub ) {
$sub_id = isset( $sub['subscription_id'] ) ? strval( $sub['subscription_id'] ) : '';
if ( empty( $sub_id ) || in_array( $sub_id, $seen_ids, true ) ) {
continue;
}
$seen_ids[] = $sub_id;
$unique_subs[] = $sub;
}
return $unique_subs;
}
/**
* Render the subscriptions section.
*
* @param array<int,array<string,mixed>> $subscriptions Subscription records.
* @since 2.8.0
* @return void
*/
private function render_subscriptions_section( $subscriptions ) {
$active_count = 0;
$cancelled_count = 0;
foreach ( $subscriptions as $sub ) {
$status = isset( $sub['subscription_status'] ) && is_scalar( $sub['subscription_status'] ) ? strval( $sub['subscription_status'] ) : '';
if ( in_array( $status, [ 'active', 'trialing' ], true ) ) {
++$active_count;
} elseif ( 'canceled' === $status ) {
++$cancelled_count;
}
}
$count_parts = [];
if ( $active_count > 0 ) {
/* translators: %d: number of active subscriptions */
$count_parts[] = sprintf( _n( '%d active', '%d active', $active_count, 'sureforms' ), $active_count );
}
if ( $cancelled_count > 0 ) {
/* translators: %d: number of cancelled subscriptions */
$count_parts[] = sprintf( _n( '%d cancelled', '%d cancelled', $cancelled_count, 'sureforms' ), $cancelled_count );
}
$count_text = implode( ' · ', $count_parts );
?>
<div class="srfm-pd-section">
<div class="srfm-pd-section-header">
<span class="srfm-pd-section-title"><?php esc_html_e( 'Subscriptions', 'sureforms' ); ?></span>
<?php if ( ! empty( $count_text ) ) { ?>
<span class="srfm-pd-section-count"><?php echo esc_html( $count_text ); ?></span>
<?php } ?>
</div>
<?php foreach ( $subscriptions as $index => $sub ) { ?>
<?php $this->render_subscription_row( $sub, $index ); ?>
<?php } ?>
</div>
<?php
}
/**
* Render a single subscription row.
*
* @param array<string,mixed> $sub Subscription record.
* @param int $index Row index.
* @since 2.8.0
* @return void
*/
private function render_subscription_row( $sub, $index ) {
$status = isset( $sub['subscription_status'] ) && is_scalar( $sub['subscription_status'] ) ? strval( $sub['subscription_status'] ) : '';
$is_active = in_array( $status, [ 'active', 'trialing' ], true );
$is_cancelled = 'canceled' === $status;
$currency = isset( $sub['currency'] ) && is_string( $sub['currency'] ) ? strtoupper( $sub['currency'] ) : 'USD';
$form_title = $this->get_form_title( isset( $sub['form_id'] ) && is_numeric( $sub['form_id'] ) ? absint( $sub['form_id'] ) : 0 );
$sub_data = $this->extract_subscription_data( $sub );
$amount_text = $this->format_amount( isset( $sub['total_amount'] ) && is_numeric( $sub['total_amount'] ) ? floatval( $sub['total_amount'] ) : 0.0, $currency );
if ( ! empty( $sub_data['interval_label'] ) ) {
$amount_text .= ' / ' . $sub_data['interval_label'];
}
$meta_text = esc_html( $amount_text );
if ( $is_active && ! empty( $sub_data['next_payment'] ) ) {
/* translators: %s: next payment date */
$meta_text .= ' · ' . sprintf( esc_html__( 'Next: %s', 'sureforms' ), esc_html( $sub_data['next_payment'] ) );
} elseif ( $is_cancelled && ! empty( $sub_data['cancelled_on'] ) ) {
/* translators: %s: cancellation date */
$meta_text = '<span class="srfm-pd-strike">' . esc_html( $amount_text ) . '</span> · ' . sprintf( esc_html__( 'Cancelled %s', 'sureforms' ), esc_html( $sub_data['cancelled_on'] ) );
}
$badge_class = $is_active ? 'srfm-pd-badge--active' : ( $is_cancelled ? 'srfm-pd-badge--cancelled' : 'srfm-pd-badge--pending' );
$badge_label = $this->get_subscription_status_label( $status );
$row_class = 'srfm-pd-sub-row' . ( $is_cancelled ? ' srfm-pd-sub-row--cancelled' : '' );
$plan_name = ! empty( $sub_data['plan_name'] ) ? $sub_data['plan_name'] : $form_title;
?>
<div class="<?php echo esc_attr( $row_class ); ?>" data-index="<?php echo esc_attr( strval( $index ) ); ?>" role="button" tabindex="0">
<div class="srfm-pd-sub-row-left">
<div class="srfm-pd-sub-row-name"><?php echo esc_html( $plan_name ); ?></div>
<div class="srfm-pd-sub-row-meta"><?php echo wp_kses_post( $meta_text ); ?></div>
</div>
<div class="srfm-pd-sub-row-right">
<span class="srfm-pd-badge <?php echo esc_attr( $badge_class ); ?>">
<span class="srfm-pd-badge-dot"></span><?php echo esc_html( $badge_label ); ?>
</span>
<span class="srfm-pd-chevron" aria-hidden="true">›</span>
</div>
</div>
<?php
}
// =========================================================================
// Payments Section
// =========================================================================
/**
* Render the payment history section.
*
* @param array<int,array<string,mixed>> $payments Payment records.
* @param int $current_page Current page.
* @param int $total_pages Total pages.
* @param int $total_count Total payment count.
* @since 2.8.0
* @return void
*/
private function render_payments_section( $payments, $current_page, $total_pages, $total_count ) {
?>
<div class="srfm-pd-section">
<div class="srfm-pd-section-header">
<span class="srfm-pd-section-title"><?php esc_html_e( 'Payment History', 'sureforms' ); ?></span>
<span class="srfm-pd-section-count">
<?php
/* translators: %d: total number of transactions */
printf( esc_html( _n( '%d transaction', '%d transactions', $total_count, 'sureforms' ) ), intval( $total_count ) );
?>
</span>
</div>
<?php foreach ( $payments as $index => $payment ) { ?>
<?php $this->render_payment_row( $payment, $index ); ?>
<?php } ?>
<?php if ( $total_pages > 1 ) { ?>
<div class="srfm-pd-pagination">
<span class="srfm-pd-pagination-info">
<?php
$start = ( ( $current_page - 1 ) * count( $payments ) ) + 1;
$end = $start + count( $payments ) - 1;
printf(
/* translators: 1: start number, 2: end number, 3: total number */
esc_html__( 'Showing %1$d–%2$d of %3$d transactions', 'sureforms' ),
intval( $start ),
intval( $end ),
intval( $total_count )
);
?>
</span>
<div class="srfm-pd-pagination-links">
<?php if ( $current_page > 1 ) { ?>
<a href="<?php echo esc_url( add_query_arg( 'srfm_page', $current_page - 1 ) ); ?>" class="srfm-pd-pagination-link">
« <?php esc_html_e( 'Previous', 'sureforms' ); ?>
</a>
<?php } ?>
<?php if ( $current_page < $total_pages ) { ?>
<a href="<?php echo esc_url( add_query_arg( 'srfm_page', $current_page + 1 ) ); ?>" class="srfm-pd-pagination-link">
<?php esc_html_e( 'Next', 'sureforms' ); ?> »
</a>
<?php } ?>
</div>
</div>
<?php } ?>
</div>
<?php
}
/**
* Render a single payment row.
*
* @param array<string,mixed> $payment Payment record.
* @param int $index Row index.
* @since 2.8.0
* @return void
*/
private function render_payment_row( $payment, $index ) {
$currency = isset( $payment['currency'] ) && is_string( $payment['currency'] ) ? strtoupper( $payment['currency'] ) : 'USD';
$status = isset( $payment['status'] ) && is_scalar( $payment['status'] ) ? strval( $payment['status'] ) : 'pending';
$txn_id = ! empty( $payment['srfm_txn_id'] ) && is_scalar( $payment['srfm_txn_id'] ) ? strval( $payment['srfm_txn_id'] ) : '';
$form_title = $this->get_form_title( isset( $payment['form_id'] ) && is_numeric( $payment['form_id'] ) ? absint( $payment['form_id'] ) : 0 );
$date_format = is_string( get_option( 'date_format' ) ) ? get_option( 'date_format' ) : 'Y-m-d';
$date = isset( $payment['created_at'] ) && is_string( $payment['created_at'] )
? date_i18n( $date_format, strtotime( $payment['created_at'] ) )
: '—';
$badge_class = $this->get_payment_badge_class( $status );
$badge_label = $this->get_payment_status_label( $status );
?>
<div class="srfm-pd-pay-row" data-index="<?php echo esc_attr( strval( $index ) ); ?>" role="button" tabindex="0">
<div class="srfm-pd-pay-row-left">
<div class="srfm-pd-pay-row-form"><?php echo esc_html( $form_title ); ?></div>
<div class="srfm-pd-pay-row-id"><?php echo esc_html( $txn_id . ( $txn_id ? ' · ' : '' ) . $date ); ?></div>
</div>
<div class="srfm-pd-pay-row-right">
<span class="srfm-pd-badge <?php echo esc_attr( $badge_class ); ?>">
<span class="srfm-pd-badge-dot"></span><?php echo esc_html( $badge_label ); ?>
</span>
<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>
<span class="srfm-pd-chevron" aria-hidden="true">›</span>
</div>
</div>
<?php
}
// =========================================================================
// JS Data Output
// =========================================================================
/**
* Output subscription and payment data as inline JSON for JS overlays.
*
* @param array<int,array<string,mixed>> $subscriptions Subscription records.
* @param array<int,array<string,mixed>> $payments Payment records.
* @since 2.8.0
* @return void
*/
private function output_js_data( $subscriptions, $payments ) {
$date_format_opt = get_option( 'date_format' );
$date_format = is_string( $date_format_opt ) ? $date_format_opt : 'Y-m-d';
$subs_data = [];
foreach ( $subscriptions as $sub ) {
$currency = isset( $sub['currency'] ) && is_string( $sub['currency'] ) ? strtoupper( $sub['currency'] ) : 'USD';
$form_title = $this->get_form_title( isset( $sub['form_id'] ) && is_numeric( $sub['form_id'] ) ? absint( $sub['form_id'] ) : 0 );
$sub_info = $this->extract_subscription_data( $sub );
$status = isset( $sub['subscription_status'] ) && is_scalar( $sub['subscription_status'] ) ? strval( $sub['subscription_status'] ) : '';
$is_active = in_array( $status, [ 'active', 'trialing' ], true );
$amount_display = $this->format_amount( isset( $sub['total_amount'] ) && is_numeric( $sub['total_amount'] ) ? floatval( $sub['total_amount'] ) : 0.0, $currency );
if ( ! empty( $sub_info['interval_label'] ) ) {
$amount_display .= ' / ' . $sub_info['interval_label'];
}
$sub_id = isset( $sub['id'] ) && is_numeric( $sub['id'] ) ? $sub['id'] : 0;
$subs_data[] = [
'id' => absint( $sub_id ),
'name' => ! empty( $sub_info['plan_name'] ) ? $sub_info['plan_name'] : $form_title,
'form' => $form_title,
'amount' => $amount_display,
'next' => $sub_info['next_payment'],
'gateway' => $this->format_gateway_label( isset( $sub['gateway'] ) && is_scalar( $sub['gateway'] ) ? strval( $sub['gateway'] ) : '' ),
'started' => isset( $sub['created_at'] ) && is_string( $sub['created_at'] )
? date_i18n( $date_format, strtotime( $sub['created_at'] ) ) : '—',
'status' => $status,
'cancelledOn' => $sub_info['cancelled_on'],
'accessUntil' => $sub_info['access_until'],
'canCancel' => $is_active,
'subscriptionId' => isset( $sub['subscription_id'] ) && is_scalar( $sub['subscription_id'] ) ? strval( $sub['subscription_id'] ) : '',
'paymentId' => absint( $sub_id ),
];
}
$txs_data = [];
foreach ( $payments as $payment ) {
$currency = isset( $payment['currency'] ) && is_string( $payment['currency'] ) ? strtoupper( $payment['currency'] ) : 'USD';
$status = isset( $payment['status'] ) && is_scalar( $payment['status'] ) ? strval( $payment['status'] ) : 'pending';
$type = isset( $payment['type'] ) && is_scalar( $payment['type'] ) ? strval( $payment['type'] ) : 'payment';
$form_title = $this->get_form_title( isset( $payment['form_id'] ) && is_numeric( $payment['form_id'] ) ? absint( $payment['form_id'] ) : 0 );
$payment_id = isset( $payment['id'] ) && is_numeric( $payment['id'] ) ? $payment['id'] : 0;
$tx_item = [
'id' => ! empty( $payment['srfm_txn_id'] ) && is_scalar( $payment['srfm_txn_id'] ) ? strval( $payment['srfm_txn_id'] ) : 'SF-' . absint( $payment_id ),
'paymentId' => absint( $payment_id ),
'form' => $form_title,
'date' => isset( $payment['created_at'] ) && is_string( $payment['created_at'] )
? date_i18n( $date_format, strtotime( $payment['created_at'] ) ) : '—',
'amount' => $this->format_amount( isset( $payment['total_amount'] ) && is_numeric( $payment['total_amount'] ) ? floatval( $payment['total_amount'] ) : 0.0, $currency ),
'status' => $status,
'type' => in_array( $type, [ 'subscription', 'renewal' ], true ) ? 'subscription' : 'single',
'gateway' => $this->format_gateway_label( isset( $payment['gateway'] ) && is_scalar( $payment['gateway'] ) ? strval( $payment['gateway'] ) : '' ),
'txn' => isset( $payment['transaction_id'] ) && is_scalar( $payment['transaction_id'] ) ? strval( $payment['transaction_id'] ) : '',
];
if ( in_array( $type, [ 'subscription', 'renewal' ], true ) && ! empty( $payment['subscription_id'] ) ) {
$sub_info = $this->extract_subscription_data( $payment );
$amount_display = $this->format_amount( isset( $payment['total_amount'] ) && is_numeric( $payment['total_amount'] ) ? floatval( $payment['total_amount'] ) : 0.0, $currency );
if ( ! empty( $sub_info['interval_label'] ) ) {
$amount_display .= ' / ' . $sub_info['interval_label'];
}
$tx_item['sub'] = [
'name' => ! empty( $sub_info['plan_name'] ) ? $sub_info['plan_name'] : $form_title,
'interval' => $amount_display,
'next' => $sub_info['next_payment'],
'status' => $this->get_subscription_status_label( isset( $payment['subscription_status'] ) && is_scalar( $payment['subscription_status'] ) ? strval( $payment['subscription_status'] ) : '' ),
];
}
$txs_data[] = $tx_item;
}
$inline_data = sprintf(
'window.srfmDashboardSubs=%s;window.srfmDashboardTxs=%s;',
wp_json_encode( $subs_data, JSON_HEX_TAG | JSON_HEX_AMP ),
wp_json_encode( $txs_data, JSON_HEX_TAG | JSON_HEX_AMP )
);
wp_add_inline_script( 'srfm-payment-history', $inline_data, 'before' );
}
// =========================================================================
// Data Extraction Helpers
// =========================================================================
/**
* Extract subscription-specific data from a payment record.
*
* @param array<string,mixed> $payment Payment record.
* @since 2.8.0
* @return array{plan_name:string,interval_label:string,next_payment:string,cancelled_on:string,access_until:string}
*/
private function extract_subscription_data( $payment ) {
$data = [
'plan_name' => '',
'interval_label' => '',
'next_payment' => '',
'cancelled_on' => '',
'access_until' => '',
];
$payment_data = $this->parse_json_field( $payment['payment_data'] ?? '' );
$extra = $this->parse_json_field( $payment['extra'] ?? '' );
$data['plan_name'] = $this->get_string_from_sources( 'plan_name', $payment_data, $extra );
$interval = $this->get_string_from_sources( 'interval', $payment_data, $extra );
$interval_count = $this->get_string_from_sources( 'interval_count', $payment_data, $extra );
if ( ! empty( $interval ) ) {
$data['interval_label'] = $this->format_interval( $interval, intval( $interval_count ? $interval_count : '1' ) );
}
$next_date = $this->get_string_from_sources( 'current_period_end', $payment_data, $extra );
if ( ! empty( $next_date ) ) {
$data['next_payment'] = $this->format_timestamp( $next_date );
}
$cancelled_at = $this->get_string_from_sources( 'canceled_at', $payment_data, $extra );
if ( ! empty( $cancelled_at ) ) {
$data['cancelled_on'] = $this->format_timestamp( $cancelled_at );
} elseif ( isset( $payment['updated_at'] ) && is_string( $payment['updated_at'] ) && 'canceled' === ( $payment['subscription_status'] ?? '' ) ) {
$date_fmt = get_option( 'date_format' );
$data['cancelled_on'] = date_i18n( is_string( $date_fmt ) ? $date_fmt : 'Y-m-d', strtotime( $payment['updated_at'] ) );
}
if ( ! empty( $next_date ) && 'canceled' === ( $payment['subscription_status'] ?? '' ) ) {
$data['access_until'] = $this->format_timestamp( $next_date );
}
return $data;
}
/**
* Parse a JSON field that may be a string or already an array.
*
* @param mixed $value Field value.
* @since 2.8.0
* @return array<string,mixed>
*/
private function parse_json_field( $value ) {
if ( is_array( $value ) ) {
return $value;
}
if ( is_string( $value ) && ! empty( $value ) ) {
$decoded = json_decode( $value, true );
return is_array( $decoded ) ? $decoded : [];
}
return [];
}
/**
* Get a string value from two data source arrays.
*
* @param string $key Key to look for.
* @param array<string,mixed> $data1 Primary data source.
* @param array<string,mixed> $data2 Fallback data source.
* @since 2.8.0
* @return string
*/
private function get_string_from_sources( $key, $data1, $data2 ) {
if ( ! empty( $data1[ $key ] ) && is_scalar( $data1[ $key ] ) ) {
return strval( $data1[ $key ] );
}
if ( ! empty( $data2[ $key ] ) && is_scalar( $data2[ $key ] ) ) {
return strval( $data2[ $key ] );
}
return '';
}
/**
* Format a timestamp (numeric or date string) to a localized date.
*
* @param string $value Timestamp or date string.
* @since 2.8.0
* @return string Formatted date.
*/
private function format_timestamp( $value ) {
$date_opt = get_option( 'date_format' );
$format = is_string( $date_opt ) ? $date_opt : 'Y-m-d';
return is_numeric( $value )
? date_i18n( $format, intval( $value ) )
: date_i18n( $format, strtotime( $value ) );
}
/**
* Get the form title from form_id.
*
* @param int $form_id Form post ID.
* @since 2.8.0
* @return string Form title.
*/
private function get_form_title( $form_id ) {
static $cache = [];
if ( $form_id <= 0 ) {
return __( 'Unknown Form', 'sureforms' );
}
if ( isset( $cache[ $form_id ] ) ) {
return $cache[ $form_id ];
}
$title = get_the_title( $form_id );
$cache[ $form_id ] = ! empty( $title ) ? $title : __( 'Unknown Form', 'sureforms' );
return $cache[ $form_id ];
}
// =========================================================================
// Formatting Helpers
// =========================================================================
/**
* Format a payment amount with currency symbol.
*
* @param float $amount Payment amount.
* @param string $currency Currency code.
* @since 2.8.0
* @return string Formatted amount.
*/
private function format_amount( $amount, $currency ) {
$symbol = Payment_Helper::get_currency_symbol( $currency );
$position = Payment_Helper::get_currency_sign_position();
$formatted = Payment_Helper::is_zero_decimal_currency( $currency )
? number_format( $amount, 0 )
: number_format( $amount, 2 );
switch ( $position ) {
case 'right':
return $formatted . $symbol;
case 'left_space':
return $symbol . ' ' . $formatted;
case 'right_space':
return $formatted . ' ' . $symbol;
case 'left':
default:
return $symbol . $formatted;
}
}
/**
* Format subscription interval to human-readable label.
*
* @param string $interval Interval type (day, week, month, year).
* @param int $interval_count Interval count.
* @since 2.8.0
* @return string
*/
private function format_interval( $interval, $interval_count = 1 ) {
$labels = [
'day' => _x( 'day', 'billing interval', 'sureforms' ),
'week' => _x( 'wk', 'billing interval', 'sureforms' ),
'month' => _x( 'mo', 'billing interval', 'sureforms' ),
'year' => _x( 'yr', 'billing interval', 'sureforms' ),
];
$label = $labels[ $interval ] ?? $interval;
return $interval_count > 1 ? $interval_count . ' ' . $label : $label;
}
/**
* Format a gateway identifier into a display label.
*
* @param string $gateway Gateway identifier (e.g., 'stripe', 'paypal').
* @since 2.8.0
* @return string Display label.
*/
private function format_gateway_label( $gateway ) {
$labels = [
'stripe' => 'Stripe',
'paypal' => 'PayPal',
];
/**
* Filter the gateway display labels map.
*
* @since 2.8.0
* @param array<string,string> $labels Gateway ID to display label map.
*/
$labels = apply_filters( 'srfm_payment_history_gateway_labels', $labels );
return $labels[ $gateway ] ?? ucfirst( $gateway );
}
/**
* Get subscription status label.
*
* @param string $status Subscription status.
* @since 2.8.0
* @return string
*/
private function get_subscription_status_label( $status ) {
$labels = [
'active' => __( 'Active', 'sureforms' ),
'trialing' => __( 'Trialing', 'sureforms' ),
'canceled' => __( 'Cancelled', 'sureforms' ),
'past_due' => __( 'Past Due', 'sureforms' ),
'paused' => __( 'Paused', 'sureforms' ),
];
return $labels[ $status ] ?? ucfirst( str_replace( '_', ' ', $status ) );
}
/**
* Get payment status label.
*
* @param string $status Payment status.
* @since 2.8.0
* @return string
*/
private function get_payment_status_label( $status ) {
$labels = [
'succeeded' => __( 'Paid', 'sureforms' ),
'pending' => __( 'Pending', 'sureforms' ),
'failed' => __( 'Failed', 'sureforms' ),
'canceled' => __( 'Cancelled', 'sureforms' ),
'refunded' => __( 'Refunded', 'sureforms' ),
'partially_refunded' => __( 'Partially Refunded', 'sureforms' ),
'processing' => __( 'Processing', 'sureforms' ),
'active' => __( 'Active', 'sureforms' ),
];
return $labels[ $status ] ?? ucfirst( str_replace( '_', ' ', $status ) );
}
/**
* Get payment status badge CSS class.
*
* @param string $status Payment status.
* @since 2.8.0
* @return string
*/
private function get_payment_badge_class( $status ) {
$map = [
'succeeded' => 'srfm-pd-badge--paid',
'active' => 'srfm-pd-badge--paid',
'pending' => 'srfm-pd-badge--pending',
'processing' => 'srfm-pd-badge--pending',
'failed' => 'srfm-pd-badge--cancelled',
'canceled' => 'srfm-pd-badge--cancelled',
'refunded' => 'srfm-pd-badge--refunded',
'partially_refunded' => 'srfm-pd-badge--refunded',
];
return $map[ $status ] ?? 'srfm-pd-badge--pending';
}
// =========================================================================
// Query Helpers
// =========================================================================
/**
* Build WHERE conditions for the payment query.
*
* @param int $user_id WordPress user ID.
* @param array<string,string> $atts Shortcode attributes.
* @since 2.8.0
* @return array<int,array<int|string,array<string,mixed>|string>> WHERE conditions.
*/
private function build_where_conditions( $user_id, $atts ) {
$stripe_customer_id = get_user_meta( $user_id, 'srfm_stripe_customer_id', true );
$or_conditions = [];
if ( ! empty( $stripe_customer_id ) && is_string( $stripe_customer_id ) ) {
$or_conditions[] = [
'key' => 'customer_id',
'compare' => '=',
'value' => $stripe_customer_id,
];
}
$where = [];
if ( ! empty( $or_conditions ) ) {
$or_conditions['RELATION'] = 'OR';
$where[] = $or_conditions;
} else {
// No customer ID found — return a zero-result condition to prevent data leakage.
// Pro gateways (e.g., PayPal) may add their own customer_id conditions via the filter below.
$where[] = [
[
'key' => 'customer_id',
'compare' => '=',
'value' => 'no_customer_' . $user_id,
],
'RELATION' => 'OR',
];
}
/**
* Filter the supported payment gateways for payment history.
*
* Free plugin defaults to ['stripe']. Pro can add additional gateways
* (e.g., 'paypal') by hooking into this filter.
*
* @since 2.8.0
* @param array<string> $gateways Array of supported gateway identifiers.
*/
$supported_gateways = apply_filters( 'srfm_payment_history_supported_gateways', [ 'stripe' ] );
$supported_gateways = array_map( 'sanitize_text_field', $supported_gateways );
$supported_gateways = array_filter( $supported_gateways );
if ( ! empty( $supported_gateways ) ) {
if ( 1 === count( $supported_gateways ) ) {
$where[] = [
[
'key' => 'gateway',
'compare' => '=',
'value' => reset( $supported_gateways ),
],
];
} else {
$where[] = [
[
'key' => 'gateway',
'compare' => 'IN',
'value' => array_values( $supported_gateways ),
],
];
}
}
/**
* Filter the WHERE conditions for the payment history query.
*
* @since 2.8.0
* @param array<int,array<int|string,array<string,mixed>|string>> $where WHERE conditions array.
* @param int $user_id WordPress user ID.
* @param array<string,string> $atts Shortcode attributes.
*/
return apply_filters( 'srfm_payment_history_where_conditions', $where, $user_id, $atts );
}
/**
* Check if the current user owns the payment record.
*
* @param array<string,mixed> $payment Payment record.
* @param int $user_id WordPress user ID.
* @since 2.8.0
* @return bool
*/
private function user_owns_payment( $payment, $user_id ) {
$stripe_customer_id = get_user_meta( $user_id, 'srfm_stripe_customer_id', true );
// Only use system-assigned customer_id for ownership — email matching is not safe
// for destructive actions (e.g., cancellation) because WP account email is user-controlled.
if ( ! empty( $stripe_customer_id ) && ! empty( $payment['customer_id'] ) && $stripe_customer_id === $payment['customer_id'] ) {
return true;
}
/**
* Filter whether the user owns the payment.
*
* @since 2.8.0
* @param bool $owns Whether the user owns the payment.
* @param array<string,mixed> $payment Payment record.
* @param int $user_id WordPress user ID.
*/
return (bool) apply_filters( 'srfm_payment_history_user_owns_payment', false, $payment, $user_id );
}
// =========================================================================
// Messages
// =========================================================================
/**
* Get the login required message.
*
* @since 2.8.0
* @return string HTML login message.
*/
private function get_login_message() {
$login_url = wp_login_url( (string) get_permalink() );
$html = sprintf(
'<div class="srfm-pd-widget"><div class="srfm-pd-message">%s <a href="%s">%s</a> %s</div></div>',
esc_html__( 'Please', 'sureforms' ),
esc_url( $login_url ),
esc_html__( 'log in', 'sureforms' ),
esc_html__( 'to view your payment dashboard.', 'sureforms' )
);
/**
* Filter the login required message HTML.
*
* @since 2.8.0
*/
return apply_filters( 'srfm_payment_history_login_message', $html );
}
/**
* Get the no payments found message.
*
* @since 2.8.0
* @return string HTML empty message.
*/
private function get_empty_message() {
$html = sprintf(
'<div class="srfm-pd-widget"><div class="srfm-pd-message">%s</div></div>',
esc_html__( 'No payments found.', 'sureforms' )
);
/**
* Filter the no payments found message HTML.
*
* @since 2.8.0
*/
return apply_filters( 'srfm_payment_history_empty_message', $html );
}
}