| 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 |
« <?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' ); ?> » |
| 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 |
|