PluginProbe
Better Payment – Instant Payments, Donations, Fundraising with Subscriptions & More / 2.4.1
Better Payment – Instant Payments, Donations, Fundraising with Subscriptions & More v2.4.1
2.4.1 2.4.0 2.3.4 2.3.3 2.3.2 2.3.1 2.3.0 2.2.2 2.2.1 2.2.0 2.1.2 2.1.1 trunk 0.0.1 0.0.2 0.0.3 0.0.4 0.0.5 0.0.6 0.0.7 1.0.0 1.0.1 1.0.2 1.0.3 1.0.4 All 68 releases
better-payment / includes / API / AdminAPI.php

AdminAPI.php in Better Payment – Instant Payments, Donations, Fundraising with Subscriptions & More 2.4.1, at includes/API/AdminAPI.php

1,439 lines 54.9 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2
3 namespace Better_Payment\Lite\API;
4
5 use Better_Payment\Lite\Admin\DB;
6 use Better_Payment\Lite\Admin\SubscriptionListFilter;
7 use Better_Payment\Lite\Models\SubscriptionRelationModel;
8 use Better_Payment\Lite\Traits\Helper;
9 use Better_Payment\Lite\WooCommerce\Subscriptions as WooSubscriptions;
10 use WP_Error;
11 use WP_REST_Controller;
12 use WP_REST_Request;
13 use WP_REST_Response;
14 use WP_REST_Server;
15
16 /**
17 * Exit if accessed directly
18 */
19 if (!defined('ABSPATH')) {
20 exit;
21 }
22
23 /**
24 * Admin REST API Controller
25 *
26 * @since 1.5.0
27 */
28 class AdminAPI extends WP_REST_Controller
29 {
30 use Helper;
31
32 /**
33 * Namespace
34 *
35 * @var string
36 */
37 protected $namespace = 'better-payment/v1';
38
39 /**
40 * Constructor
41 *
42 * @since 1.5.0
43 */
44 public function __construct()
45 {
46 add_action('rest_api_init', [$this, 'register_routes']);
47 }
48
49 /**
50 * Register REST API routes
51 *
52 * @since 1.5.0
53 */
54 public function register_routes()
55 {
56 register_rest_route($this->namespace, '/transactions', [
57 'methods' => WP_REST_Server::READABLE,
58 'callback' => [$this, 'get_transactions'],
59 'permission_callback' => [$this, 'check_admin_permissions'],
60 'args' => [
61 // A tab. Anything else is `all` (no `type`, so a non-string falls back too instead of a 400).
62 'status' => [
63 'required' => false,
64 'default' => 'all',
65 'sanitize_callback' => [__CLASS__, 'sanitize_transaction_status']
66 ]
67 ]
68 ]);
69
70 register_rest_route($this->namespace, '/subscriptions', [
71 'methods' => WP_REST_Server::READABLE,
72 'callback' => [$this, 'get_subscriptions'],
73 'permission_callback' => [$this, 'check_admin_permissions']
74 ]);
75
76 // Literal segment — never collides with the (?P<id>\d+) routes below,
77 // whose regex only matches digits.
78 register_rest_route($this->namespace, '/subscriptions/count', [
79 'methods' => WP_REST_Server::READABLE,
80 'callback' => [$this, 'get_subscription_count'],
81 'permission_callback' => [$this, 'check_admin_permissions']
82 ]);
83
84 // A subscription is identified by (subscription_id, source) — the id
85 // alone is ambiguous (the same numeric id can exist under two
86 // sources), so every single-subscription route requires ?source=.
87 register_rest_route($this->namespace, '/subscriptions/(?P<id>\d+)', [
88 'methods' => WP_REST_Server::READABLE,
89 'callback' => [$this, 'get_subscription'],
90 'permission_callback' => [$this, 'check_admin_permissions']
91 ]);
92
93 register_rest_route($this->namespace, '/subscriptions/(?P<id>\d+)', [
94 'methods' => WP_REST_Server::DELETABLE,
95 'callback' => [$this, 'delete_subscription'],
96 'permission_callback' => [$this, 'check_admin_permissions']
97 ]);
98
99 register_rest_route($this->namespace, '/subscriptions/(?P<id>\d+)/status', [
100 'methods' => WP_REST_Server::CREATABLE,
101 'callback' => [$this, 'update_subscription_status'],
102 'permission_callback' => [$this, 'check_admin_permissions']
103 ]);
104
105 register_rest_route($this->namespace, '/transactions/count', [
106 'methods' => WP_REST_Server::READABLE,
107 'callback' => [$this, 'get_transaction_count'],
108 'permission_callback' => [$this, 'check_admin_permissions']
109 ]);
110
111 register_rest_route($this->namespace, '/transactions/(?P<id>\d+)', [
112 'methods' => WP_REST_Server::DELETABLE,
113 'callback' => [$this, 'delete_transaction'],
114 'permission_callback' => [$this, 'check_admin_permissions']
115 ]);
116
117 register_rest_route($this->namespace, '/transactions/(?P<id>\d+)', [
118 'methods' => WP_REST_Server::READABLE,
119 'callback' => [$this, 'get_transaction'],
120 'permission_callback' => [$this, 'check_admin_permissions']
121 ]);
122
123 register_rest_route($this->namespace, '/transactions/(?P<id>\d+)/mark-complete', [
124 'methods' => WP_REST_Server::CREATABLE,
125 'callback' => [$this, 'mark_transaction_complete'],
126 'permission_callback' => [$this, 'check_admin_permissions']
127 ]);
128
129 register_rest_route($this->namespace, '/transactions/(?P<id>\d+)/referer', [
130 'methods' => WP_REST_Server::READABLE,
131 'callback' => [$this, 'get_transaction_referer'],
132 'permission_callback' => [$this, 'check_admin_permissions']
133 ]);
134
135 register_rest_route($this->namespace, '/settings', [
136 [
137 'methods' => WP_REST_Server::READABLE,
138 'callback' => [$this, 'get_settings'],
139 'permission_callback' => [$this, 'check_admin_permissions']
140 ],
141 [
142 'methods' => WP_REST_Server::CREATABLE,
143 'callback' => [$this, 'update_settings'],
144 'permission_callback' => [$this, 'check_admin_permissions']
145 ]
146 ]);
147
148 register_rest_route($this->namespace, '/dashboard/dismissible-section', [
149 'methods' => WP_REST_Server::READABLE,
150 'callback' => [$this, 'show_dismissible_section'],
151 'permission_callback' => [$this, 'check_admin_permissions']
152 ]);
153
154 register_rest_route($this->namespace, '/dashboard/dismissible-section-data', [
155 'methods' => WP_REST_Server::READABLE,
156 'callback' => [$this, 'get_dismissible_section_data'],
157 'permission_callback' => [$this, 'check_admin_permissions']
158 ]);
159
160 register_rest_route($this->namespace, '/dashboard/dismissible-section-dismiss', [
161 'methods' => WP_REST_Server::READABLE,
162 'callback' => [$this, 'dismiss_dismissible_section'],
163 'permission_callback' => [$this, 'check_admin_permissions']
164 ]);
165
166 register_rest_route($this->namespace, '/dashboard/sale-info-dismissed', [
167 'methods' => WP_REST_Server::READABLE,
168 'callback' => [$this, 'is_sale_info_dismissed'],
169 'permission_callback' => [$this, 'check_admin_permissions']
170 ]);
171
172 register_rest_route($this->namespace, '/dashboard/sale-info-dismiss', [
173 'methods' => WP_REST_Server::READABLE,
174 'callback' => [$this, 'dismiss_sale_info'],
175 'permission_callback' => [$this, 'check_admin_permissions']
176 ]);
177
178 register_rest_route($this->namespace, '/options', [
179 'methods' => WP_REST_Server::READABLE,
180 'callback' => [$this, 'get_options'],
181 'permission_callback' => [$this, 'check_admin_permissions']
182 ]);
183
184 // FluentCart product search
185 register_rest_route($this->namespace, '/fluentcart-products', [
186 'methods' => WP_REST_Server::READABLE,
187 'callback' => [$this, 'search_fluentcart_products'],
188 'permission_callback' => [$this, 'check_admin_permissions'],
189 'args' => [
190 'search' => [
191 'required' => false,
192 'type' => 'string',
193 'sanitize_callback' => 'sanitize_text_field'
194 ]
195 ]
196 ]);
197
198 // FluentCart single product
199 register_rest_route($this->namespace, '/fluentcart-product/(?P<id>\d+)', [
200 'methods' => WP_REST_Server::READABLE,
201 'callback' => [$this, 'get_fluentcart_product'],
202 'permission_callback' => [$this, 'check_admin_permissions'],
203 'args' => [
204 'id' => [
205 'required' => true,
206 'type' => 'integer',
207 'sanitize_callback' => 'absint'
208 ]
209 ]
210 ]);
211
212 // Stripe price details - fetches product name and price from Stripe API
213 register_rest_route($this->namespace, '/stripe-price', [
214 'methods' => WP_REST_Server::READABLE,
215 'callback' => [$this, 'get_stripe_price'],
216 'permission_callback' => [$this, 'check_admin_permissions'],
217 'args' => [
218 'price_id' => [
219 'required' => true,
220 'type' => 'string',
221 'sanitize_callback' => 'sanitize_text_field'
222 ]
223 ]
224 ]);
225 }
226
227 /**
228 * Get transaction count
229 *
230 * @since 1.5.0
231 * @param WP_REST_Request $request
232 * @return WP_REST_Response|WP_Error
233 */
234 public function get_transaction_count($request) {
235 if (!$this->bp_valid_nonce($request)) {
236 return new WP_Error('invalid_nonce', 'Invalid Request', ['status' => 403]);
237 }
238
239 $all = DB::get_transaction_count();
240 $completed = DB::get_transaction_count('', 'v2', 0, 'completed');
241 $incomplete = DB::get_transaction_count('', 'v2', 1, 'incomplete');
242
243 return rest_ensure_response([
244 'success' => true,
245 'count' => [
246 'all' => $all,
247 'completed' => $completed,
248 'incomplete' => $incomplete
249 ]
250 ]);
251 }
252
253 public function get_options($request) {
254 if (!$this->bp_valid_nonce($request)) {
255 return new WP_Error('invalid_nonce', 'Invalid Request', ['status' => 403]);
256 }
257
258 $option_name = sanitize_text_field($request->get_param('option_name'));
259 $options = get_option($option_name, true);
260 if (!$options) {
261 return rest_ensure_response("UTC");
262 }
263 return rest_ensure_response($options);
264 }
265
266 /**
267 * Check if sale info is dismissed
268 *
269 * @since 1.5.0
270 * @param WP_REST_Request $request
271 * @return WP_REST_Response|WP_Error
272 */
273 public function is_sale_info_dismissed($request) {
274 if (!$this->bp_valid_nonce($request)) {
275 return new WP_Error('invalid_nonce', 'Invalid Request', ['status' => 403]);
276 }
277
278 $is_sale_info_dismissed = get_option('better_payment_sale_info_dismissed', false);
279
280 return rest_ensure_response([
281 'success' => true,
282 'isSaleInfoDismissed' => $is_sale_info_dismissed
283 ]);
284 }
285
286 /**
287 * Dismiss sale info
288 *
289 * @since 1.5.0
290 * @param WP_REST_Request $request
291 * @return WP_REST_Response|WP_Error
292 */
293 public function dismiss_sale_info($request) {
294 if (!$this->bp_valid_nonce($request)) {
295 return new WP_Error('invalid_nonce', 'Invalid Request', ['status' => 403]);
296 }
297
298 $dismissed = update_option('better_payment_sale_info_dismissed', true);
299
300 return rest_ensure_response([
301 'success' => $dismissed,
302 'message' => __('Sale info dismissed', 'better-payment')
303 ]);
304 }
305
306 /**
307 * Dismiss dismissible section
308 *
309 * @since 1.5.0
310 * @param WP_REST_Request $request
311 * @return WP_REST_Response|WP_Error
312 */
313 public function dismiss_dismissible_section($request) {
314 if (!$this->bp_valid_nonce($request)) {
315 return new WP_Error('invalid_nonce', 'Invalid Request', ['status' => 403]);
316 }
317
318 $dismissed = update_option('better_payment_progress_bar_dismissed', true);
319
320 return rest_ensure_response([
321 'success' => $dismissed,
322 'message' => __('Dismissible section dismissed', 'better-payment')
323 ]);
324 }
325
326 /**
327 * Validate nonce
328 *
329 * @since 1.5.0
330 * @param WP_REST_Request $request
331 * @return bool
332 */
333 private function bp_valid_nonce($request) {
334 $nonce = $request->get_header('x_wp_nonce');
335 if (!wp_verify_nonce($nonce, 'wp_rest')) {
336 return false;
337 }
338 return true;
339 }
340
341 /**
342 * Get dismissible section data
343 *
344 * @since 1.5.0
345 * @param WP_REST_Request $request
346 * @return WP_REST_Response|WP_Error
347 */
348 public function get_dismissible_section_data($request) {
349 if (!$this->bp_valid_nonce($request)) {
350 return new WP_Error('invalid_nonce', 'Invalid Request', ['status' => 403]);
351 }
352
353 $settings = DB::get_settings();
354 $progress_steps['steps'] = $this->bp_calculate_progress_steps($settings);
355 $completed_steps = count( array_filter($progress_steps['steps'], function($step) { return $step['completed']; }) );
356 $total_steps = count($progress_steps['steps']);
357 $progress_steps['percentage'] = $total_steps > 0 ? ($completed_steps / $total_steps) * 100 : 0;
358
359 return rest_ensure_response([
360 'success' => true,
361 'data' => $progress_steps
362 ]);
363 }
364
365 /**
366 * Check if dismissible section should be shown
367 *
368 * @since 1.5.0
369 * @param WP_REST_Request $request
370 * @return WP_REST_Response|WP_Error
371 */
372 public function show_dismissible_section($request) {
373 if (!$this->bp_valid_nonce($request)) {
374 return new WP_Error('invalid_nonce', 'Invalid Request', ['status' => 403]);
375 }
376
377 $show_dismissible_section = $this->bp_section_dismissed();
378
379 return rest_ensure_response([
380 'success' => true,
381 'sectionDismissed' => $show_dismissible_section
382 ]);
383 }
384
385 /**
386 * Check admin permissions
387 *
388 * @since 1.5.0
389 * @return bool
390 */
391 public function check_admin_permissions()
392 {
393 if (!current_user_can('manage_options')) {
394 return new WP_Error('unauthorized', 'Unauthorized', ['status' => 401]);
395 }
396 return true;
397 }
398
399 /**
400 * The `status` param of GET /transactions: a v2 tab (`all`, `completed`, `incomplete`, `refunded`).
401 *
402 * Anything else is `all`, as DB::get_transactions() reads an unknown status. An unknown value used
403 * to reach the count query as a transaction type and fatal in array_map() over a string. Runs as
404 * the route arg's sanitize_callback and again in the handler.
405 *
406 * @since 2.4.0
407 *
408 * @param mixed $value Raw param.
409 * @return string
410 */
411 public static function sanitize_transaction_status($value): string
412 {
413 $status = is_string($value) ? sanitize_text_field($value) : '';
414 $tabs = (new \Better_Payment\Lite\Classes\Helper())->get_transaction_types('v2');
415
416 return in_array($status, $tabs, true) ? $status : 'all';
417 }
418
419 /**
420 * Get transactions with filters
421 *
422 * @since 1.5.0
423 * @param WP_REST_Request $request
424 * @return WP_REST_Response|WP_Error
425 */
426 public function get_transactions($request)
427 {
428 if (!$this->bp_valid_nonce($request)) {
429 return new WP_Error('invalid_nonce', 'Invalid Request', ['status' => 403]);
430 }
431
432 try {
433 $paged = $request->get_param('paged') ? intval($request->get_param('paged')) : 1;
434 $per_page = $request->get_param('per_page') ? intval($request->get_param('per_page')) : 20;
435 $status = self::sanitize_transaction_status($request->get_param('status'));
436 $search_text = $request->get_param('search_text') ? sanitize_text_field($request->get_param('search_text')) : '';
437 $payment_date_from = $request->get_param('payment_date_from') ? sanitize_text_field($request->get_param('payment_date_from')) : '';
438 $payment_date_to = $request->get_param('payment_date_to') ? sanitize_text_field($request->get_param('payment_date_to')) : '';
439 $order_by = $request->get_param('order_by') ? sanitize_text_field($request->get_param('order_by')) : 'payment_date';
440 $order = $request->get_param('order') ? sanitize_text_field($request->get_param('order')) : 'DESC';
441 $source = $request->get_param('source') ? sanitize_text_field($request->get_param('source')) : '';
442 $currency = $request->get_param('currency') ? sanitize_text_field($request->get_param('currency')) : 'all';
443
444 $filters = [];
445 $filters['per_page'] = $per_page;
446 $filters['paged'] = $paged;
447 $filters['offset'] = ($paged - 1) * $per_page;
448 if ($status && $status !== 'all') {
449 $filters['status'] = $status;
450 }
451 if ($search_text) {
452 $filters['search_text'] = $search_text;
453 }
454 if ($payment_date_from) {
455 $filters['payment_date_from'] = $payment_date_from;
456 }
457 if ($payment_date_to) {
458 $filters['payment_date_to'] = $payment_date_to;
459 }
460 if ($order_by) {
461 $filters['order_by'] = $order_by;
462 }
463 if ($order) {
464 $filters['order'] = $order;
465 }
466 if ($source) {
467 $filters['source'] = $source;
468 }
469 if ($currency) {
470 $filters['currency'] = $currency;
471 }
472
473 $transactions = DB::get_transactions($filters, 0, 'v2');
474
475 foreach ($transactions as $key => $transaction) {
476 // The bucket the row is labelled under (an unconfirmed pre-2.4.0 free checkout is
477 // `incomplete`). The React list prefers it to its own raw-status map.
478 $transactions[$key]->status_type = \Better_Payment\Lite\Classes\Helper::transaction_status_type($transaction, 'v2');
479 $transactions[$key]->form_fields_info = maybe_unserialize($transaction->form_fields_info);
480 }
481 unset($filters['offset']);
482 $total = DB::get_transaction_count($filters, 'v2', null, $status);
483
484 return rest_ensure_response([
485 'transactions' => $transactions,
486 'total' => $total,
487 'page' => $paged,
488 'per_page' => $per_page,
489 'pages' => ceil($total / $per_page)
490 ]);
491 } catch (\Exception $e) {
492 return new WP_Error('transactions_error', $e->getMessage(), ['status' => 500]);
493 }
494 }
495
496 /**
497 * Get paginated E-COMMERCE subscriptions, optionally narrowed by status
498 * and/or a free-text search.
499 *
500 * Data source is the `{prefix}better_payment_subscription_order` relation
501 * table — one row per distinct (subscription_id, source) — NOT the
502 * transactions table, so Better Payment's own (Elementor/campaign) Stripe
503 * subscription payments never appear here. Each row is hydrated by its
504 * integration: 'woo' via WooSubscriptions::admin_list_row() (parent-order
505 * meta), anything else via the `better_payment/admin/subscription_list_row`
506 * filter. A row that cannot hydrate (integration inactive, order deleted)
507 * still lists with its ids rather than silently disappearing.
508 *
509 * Two paths, because the filterable fields do not exist in SQL:
510 *
511 * - **Unfiltered** (every default pageview) — the relation table is
512 * grouped, ordered and LIMITed in SQL, and only the current page's rows
513 * are ever hydrated. Unchanged from before filtering existed.
514 * - **Filtered** — status, customer, product, source label and start date
515 * all arrive during hydration, so there is nothing to push into a WHERE
516 * clause:
517 * the whole set is fetched, hydrated, filtered by
518 * SubscriptionListFilter and paginated in PHP. The extra hydration is
519 * the price of the feature and is confined to filtered requests.
520 *
521 * Both paths return the identical envelope, so the client cannot tell
522 * them apart.
523 *
524 * @since 2.4.0
525 * @param \WP_REST_Request $request
526 * @return \WP_REST_Response|\WP_Error
527 */
528 public function get_subscriptions($request)
529 {
530 if (!$this->bp_valid_nonce($request)) {
531 return new WP_Error('invalid_nonce', 'Invalid Request', ['status' => 403]);
532 }
533
534 try {
535 $paged = $request->get_param('paged') ? intval($request->get_param('paged')) : 1;
536 $per_page = $request->get_param('per_page') ? intval($request->get_param('per_page')) : 20;
537
538 $status = SubscriptionListFilter::sanitize_status($request->get_param('status'));
539 $search = SubscriptionListFilter::sanitize_search($request->get_param('search_text'));
540 // Filters the "Started" column, so the params are named for it
541 // rather than borrowed from the Transactions tab's payment_date_*.
542 $date_from = SubscriptionListFilter::sanitize_date($request->get_param('start_date_from'));
543 $date_to = SubscriptionListFilter::sanitize_date($request->get_param('start_date_to'));
544
545 if (SubscriptionListFilter::is_filtered($status, $search, $date_from, $date_to)) {
546 $rows = [];
547
548 foreach (SubscriptionRelationModel::get_subscription_groups() as $relation) {
549 $rows[] = $this->hydrate_subscription_row($relation);
550 }
551
552 $result = SubscriptionListFilter::paginate(
553 SubscriptionListFilter::apply($rows, $status, $search, $date_from, $date_to),
554 $paged,
555 $per_page
556 );
557
558 $subscriptions = $result['subscriptions'];
559 } else {
560 $result = SubscriptionRelationModel::get_subscriptions_paginated([
561 'paged' => $paged,
562 'per_page' => $per_page,
563 ]);
564
565 $subscriptions = [];
566
567 foreach ($result['subscriptions'] as $relation) {
568 $subscriptions[] = $this->hydrate_subscription_row($relation);
569 }
570 }
571
572 return rest_ensure_response([
573 'subscriptions' => $subscriptions,
574 'total' => $result['total'],
575 'page' => $result['page'],
576 'per_page' => $result['per_page'],
577 'pages' => $result['pages'],
578 ]);
579 } catch (\Exception $e) {
580 return new WP_Error('subscriptions_error', $e->getMessage(), ['status' => 500]);
581 }
582 }
583
584 /**
585 * Build one admin Subscriptions list row from a relation-table group row.
586 *
587 * The single place a display row is assembled, so the filtered and
588 * unfiltered paths of get_subscriptions() can never disagree about what a
589 * row contains — which is what makes it safe for SubscriptionListFilter
590 * to match against the same fields the list renders.
591 *
592 * @since 2.4.0
593 * @param object $relation Grouped relation row (subscription_id, source, renewal_orders).
594 * @return array Display row.
595 */
596 private function hydrate_subscription_row($relation)
597 {
598 $known_sources = SubscriptionRelationModel::known_sources();
599 $source = (string) $relation->source;
600
601 $row = [
602 'subscription_id' => (int) $relation->subscription_id,
603 'source' => $source,
604 'source_label' => isset($known_sources[$source]) ? $known_sources[$source] : ucfirst($source),
605 'renewal_orders' => (int) $relation->renewal_orders,
606 'customer_name' => '',
607 'customer_email' => '',
608 'product_name' => '',
609 'amount' => null,
610 'currency' => '',
611 'interval' => 0,
612 'period' => '',
613 'status' => '',
614 'renewal_count' => 0,
615 'next_payment' => '',
616 'start_date' => '',
617 'order_edit_url' => '',
618 ];
619
620 if (SubscriptionRelationModel::SOURCE_WOO === $source) {
621 $hydrated = WooSubscriptions::admin_list_row((int) $relation->subscription_id);
622 if (is_array($hydrated)) {
623 $row = array_merge($row, $hydrated);
624 }
625 }
626
627 /**
628 * Lets an e-commerce integration hydrate (or amend) its own
629 * subscription rows on the admin Subscriptions tab.
630 *
631 * Runs on every listed row, including on a filtered request — the
632 * admin list's status/search filters match on the row this filter
633 * returns, so an integration that hydrates `status` only here is
634 * still filterable.
635 *
636 * @since 2.4.0
637 *
638 * @param array $row Display row (see shape above).
639 * @param object $relation Raw relation-table group row (subscription_id, source, renewal_orders).
640 */
641 return apply_filters('better_payment/admin/subscription_list_row', $row, $relation);
642 }
643
644 /**
645 * Summary counts for the admin Subscriptions tab's stat cards: every
646 * recorded e-commerce subscription, plus how many are currently active
647 * or cancelled. Status comes from each subscription's integration —
648 * 'woo' via a light order-meta read (WooSubscriptions::admin_status()),
649 * anything else via the `better_payment/admin/subscription_status`
650 * filter — so a row whose integration is inactive still counts toward
651 * `all` but toward neither status bucket, mirroring how the list shows
652 * un-hydrated rows rather than dropping them.
653 *
654 * @since 2.4.0
655 * @param \WP_REST_Request $request
656 * @return \WP_REST_Response|\WP_Error
657 */
658 public function get_subscription_count($request)
659 {
660 if (!$this->bp_valid_nonce($request)) {
661 return new WP_Error('invalid_nonce', 'Invalid Request', ['status' => 403]);
662 }
663
664 try {
665 $pairs = SubscriptionRelationModel::get_distinct_subscriptions();
666
667 $active = 0;
668 $cancelled = 0;
669
670 foreach ($pairs as $pair) {
671 $source = (string) $pair->source;
672 $status = '';
673
674 if (SubscriptionRelationModel::SOURCE_WOO === $source) {
675 $status = WooSubscriptions::admin_status((int) $pair->subscription_id);
676 }
677
678 /**
679 * Lets an e-commerce integration report one subscription's
680 * current status for the admin tab's summary counts. Return
681 * the raw `_bp_subscription_status`-style value ('active',
682 * 'cancelled', 'past_due', …) or '' when unknown.
683 *
684 * @since 2.4.0
685 *
686 * @param string $status Status resolved so far ('' unless woo).
687 * @param int $subscription_id Subscription id (within its source).
688 * @param string $source Source slug.
689 */
690 $status = strtolower((string) apply_filters('better_payment/admin/subscription_status', $status, (int) $pair->subscription_id, $source));
691
692 if ('active' === $status) {
693 $active++;
694 } elseif ('cancelled' === $status) {
695 $cancelled++;
696 }
697 }
698
699 return rest_ensure_response([
700 'success' => true,
701 'count' => [
702 'all' => count($pairs),
703 'active' => $active,
704 'cancelled' => $cancelled,
705 ],
706 ]);
707 } catch (\Exception $e) {
708 return new WP_Error('subscription_count_error', $e->getMessage(), ['status' => 500]);
709 }
710 }
711
712 /**
713 * Resolve and validate the (id, source) pair every single-subscription
714 * route needs. Returns [id, source, relation order rows] or a WP_Error —
715 * a pair with no relation rows is a subscription this plugin has never
716 * recorded, i.e. 404.
717 *
718 * @since 2.4.0
719 * @param WP_REST_Request $request
720 * @return array|WP_Error [int $id, string $source, object[] $orders]
721 */
722 private function resolve_subscription($request)
723 {
724 $id = intval($request->get_param('id'));
725 $source = SubscriptionRelationModel::sanitize_source($request->get_param('source'));
726
727 if (!$id || '' === $source) {
728 return new WP_Error('invalid_subscription', __('A subscription id and source are required.', 'better-payment'), ['status' => 400]);
729 }
730
731 $orders = SubscriptionRelationModel::get_orders($id, $source);
732
733 if (empty($orders)) {
734 return new WP_Error('subscription_not_found', __('Subscription not found.', 'better-payment'), ['status' => 404]);
735 }
736
737 return [$id, $source, $orders];
738 }
739
740 /**
741 * Get one e-commerce subscription for the admin details view: the list
742 * row's fields plus details-only fields (auto renew, available status
743 * actions) and the related orders recorded in the
744 * relation table. Hydration mirrors get_subscriptions(): 'woo' via the
745 * WooCommerce module, anything else via the
746 * `better_payment/admin/subscription_detail` filter. An un-hydratable
747 * subscription still returns its base row + order ids (the UI renders
748 * dashes and offers no actions).
749 *
750 * @since 2.4.0
751 * @param WP_REST_Request $request
752 * @return WP_REST_Response|WP_Error
753 */
754 public function get_subscription($request)
755 {
756 if (!$this->bp_valid_nonce($request)) {
757 return new WP_Error('invalid_nonce', 'Invalid Request', ['status' => 403]);
758 }
759
760 $resolved = $this->resolve_subscription($request);
761 if (is_wp_error($resolved)) {
762 return $resolved;
763 }
764 list($id, $source, $relations) = $resolved;
765
766 $known_sources = SubscriptionRelationModel::known_sources();
767 $types = SubscriptionRelationModel::types();
768
769 $detail = [
770 'subscription_id' => $id,
771 'source' => $source,
772 'source_label' => isset($known_sources[$source]) ? $known_sources[$source] : ucfirst($source),
773 'customer_name' => '',
774 'customer_email' => '',
775 'product_name' => '',
776 'amount' => null,
777 'currency' => '',
778 'interval' => 0,
779 'period' => '',
780 'status' => '',
781 'renewal_count' => 0,
782 'auto_renew' => '',
783 'next_payment' => '',
784 // '' until the subscription has a settled renewal — the UI only
785 // renders the Last Payment row when this is non-empty.
786 'last_payment' => '',
787 // Who cancelled: 'customer' | 'admin', with the actor's display
788 // name. '' unless the subscription is cancelled and its
789 // cancellation recorded an actor — the UI drops the row when the
790 // type is empty.
791 'cancelled_by_type' => '',
792 'cancelled_by_name' => '',
793 'start_date' => '',
794 'order_edit_url' => '',
795 'available_actions' => [],
796 // Billing & Shipping card: plain-text address lines (never
797 // formatted-address HTML — the React admin renders text only).
798 'billing_address' => [],
799 'shipping_address' => [],
800 'billing_phone' => '',
801 'orders' => [],
802 ];
803
804 if (SubscriptionRelationModel::SOURCE_WOO === $source) {
805 $hydrated = WooSubscriptions::admin_detail($id);
806 if (is_array($hydrated)) {
807 $detail = array_merge($detail, $hydrated);
808 }
809 }
810
811 // Related orders, newest first (the relation table stores them in
812 // recording order, oldest first).
813 foreach (array_reverse($relations) as $relation) {
814 $type = (string) $relation->type;
815
816 $order_row = [
817 'order_id' => (int) $relation->order_id,
818 'type' => $type,
819 'type_label' => isset($types[$type]) ? $types[$type] : ucfirst($type),
820 'order_number' => '',
821 'date' => '',
822 'status' => '',
823 'status_label' => '',
824 'total' => null,
825 'currency' => '',
826 'edit_url' => '',
827 ];
828
829 if (SubscriptionRelationModel::SOURCE_WOO === $source) {
830 $hydrated_order = WooSubscriptions::admin_order_row((int) $relation->order_id);
831 if (is_array($hydrated_order)) {
832 $order_row = array_merge($order_row, $hydrated_order);
833 }
834 }
835
836 $detail['orders'][] = $order_row;
837 }
838
839 /**
840 * Lets an e-commerce integration hydrate (or amend) its own
841 * subscription's admin details view — including the related-order
842 * rows and the `available_actions` its status endpoint supports.
843 *
844 * @since 2.4.0
845 *
846 * @param array $detail Detail payload (see shape above).
847 * @param int $subscription_id Subscription id within its source.
848 * @param string $source Source slug ('woo', 'fluentcart', …).
849 */
850 $detail = apply_filters('better_payment/admin/subscription_detail', $detail, $id, $source);
851
852 return rest_ensure_response($detail);
853 }
854
855 /**
856 * Perform a status action (cancel | reactivate) on a subscription. The
857 * WooCommerce module handles its own source; any other integration may
858 * handle its subscriptions via the
859 * `better_payment/admin/subscription_status_action` filter — with no
860 * handler the action is refused rather than silently "succeeding".
861 *
862 * @since 2.4.0
863 * @param WP_REST_Request $request
864 * @return WP_REST_Response|WP_Error
865 */
866 public function update_subscription_status($request)
867 {
868 if (!$this->bp_valid_nonce($request)) {
869 return new WP_Error('invalid_nonce', 'Invalid Request', ['status' => 403]);
870 }
871
872 $resolved = $this->resolve_subscription($request);
873 if (is_wp_error($resolved)) {
874 return $resolved;
875 }
876 list($id, $source) = $resolved;
877
878 $action = sanitize_key((string) $request->get_param('subscription_action'));
879
880 if ('' === $action) {
881 return new WP_Error('invalid_action', __('A subscription action is required.', 'better-payment'), ['status' => 400]);
882 }
883
884 if (SubscriptionRelationModel::SOURCE_WOO === $source) {
885 $result = WooSubscriptions::admin_status_action($id, $action);
886 } else {
887 /**
888 * Lets an e-commerce integration handle admin status actions on
889 * its own subscriptions. Return true on success, a WP_Error to
890 * refuse with a message, or leave the null default to signal the
891 * action is unsupported for this source (a handler must check
892 * $source and leave other integrations' subscriptions alone).
893 *
894 * @since 2.4.0
895 *
896 * @param null|true|WP_Error $result Handling result.
897 * @param int $subscription_id Subscription id within its source.
898 * @param string $source Source slug ('fluentcart', …).
899 * @param string $action Requested action slug.
900 */
901 $result = apply_filters('better_payment/admin/subscription_status_action', null, $id, $source, $action);
902
903 if (null === $result) {
904 return new WP_Error('action_not_supported', __('This subscription cannot be managed from here — its integration does not support status changes.', 'better-payment'), ['status' => 400]);
905 }
906 }
907
908 if (is_wp_error($result)) {
909 return $result;
910 }
911
912 return rest_ensure_response([
913 'success' => true,
914 'message' => 'cancel' === $action
915 ? __('Subscription cancelled — no further renewals will be charged.', 'better-payment')
916 : __('Subscription updated successfully.', 'better-payment'),
917 ]);
918 }
919
920 /**
921 * Delete a subscription from the admin Subscriptions tab. A still-live
922 * ('woo': active/past_due) subscription is CANCELLED first — deleting
923 * only the relation rows would leave an invisible subscription renewing
924 * via cron — then its relation rows are removed so it no longer lists.
925 * Other integrations get the `better_payment/admin/subscription_delete`
926 * action to stop their side before the rows go.
927 *
928 * @since 2.4.0
929 * @param WP_REST_Request $request
930 * @return WP_REST_Response|WP_Error
931 */
932 public function delete_subscription($request)
933 {
934 if (!$this->bp_valid_nonce($request)) {
935 return new WP_Error('invalid_nonce', 'Invalid Request', ['status' => 403]);
936 }
937
938 $resolved = $this->resolve_subscription($request);
939 if (is_wp_error($resolved)) {
940 return $resolved;
941 }
942 list($id, $source) = $resolved;
943
944 if (SubscriptionRelationModel::SOURCE_WOO === $source && function_exists('wc_get_order')) {
945 $order = wc_get_order($id);
946 if ($order instanceof \WC_Order) {
947 // No-op unless the subscription is active/past_due (cancel()
948 // guards itself), so deleting a cancelled/completed
949 // subscription adds no order note.
950 WooSubscriptions::cancel($order, __('Better Payment: subscription cancelled — it was deleted from the Better Payment admin. No further automatic renewals will be charged.', 'better-payment'), 'admin');
951 }
952 }
953
954 /**
955 * Fires before a subscription's relation rows are deleted from the
956 * admin Subscriptions tab. An integration should stop the
957 * subscription on its own side here — after this, Better Payment no
958 * longer tracks it.
959 *
960 * @since 2.4.0
961 *
962 * @param int $subscription_id Subscription id within its source.
963 * @param string $source Source slug ('woo', 'fluentcart', …).
964 */
965 do_action('better_payment/admin/subscription_delete', $id, $source);
966
967 $deleted = SubscriptionRelationModel::delete_for_subscription($id, $source);
968
969 if (!$deleted) {
970 return new WP_Error('delete_failed', __('Failed to delete subscription.', 'better-payment'), ['status' => 500]);
971 }
972
973 return rest_ensure_response([
974 'success' => true,
975 'message' => __('Subscription deleted successfully.', 'better-payment'),
976 ]);
977 }
978
979 /**
980 * Get single transaction
981 *
982 * @since 1.5.0
983 * @param WP_REST_Request $request
984 * @return WP_REST_Response|WP_Error
985 */
986 public function get_transaction($request)
987 {
988 if (!$this->bp_valid_nonce($request)) {
989 return new WP_Error('invalid_nonce', 'Invalid Request', ['status' => 403]);
990 }
991
992 try {
993 $id = $request->get_param('id');
994 $transaction = DB::get_transaction($id);
995
996 if (!$transaction) {
997 return new WP_Error('transaction_not_found', 'Transaction not found', ['status' => 404]);
998 }
999
1000 // From the stored row, before form_fields_info is unserialized (see get_transactions()).
1001 $transaction->status_type = \Better_Payment\Lite\Classes\Helper::transaction_status_type($transaction, 'v2');
1002 $transaction->form_fields_info = maybe_unserialize($transaction->form_fields_info);
1003 $woo_products = maybe_unserialize($transaction->form_fields_info['detailed_product_info'] ?? []);
1004 $transaction->woo_products = $woo_products['woo_products'] ?? [];
1005 $transaction->woo_products = array_values($transaction->woo_products);
1006
1007 return rest_ensure_response($transaction);
1008 } catch (\Exception $e) {
1009 return new WP_Error('transaction_error', $e->getMessage(), ['status' => 500]);
1010 }
1011 }
1012
1013 /**
1014 * Mark transaction as complete
1015 *
1016 * @since 1.5.0
1017 * @param WP_REST_Request $request
1018 * @return WP_REST_Response|WP_Error
1019 */
1020 public function mark_transaction_complete($request)
1021 {
1022 if (!$this->bp_valid_nonce($request)) {
1023 return new WP_Error('invalid_nonce', 'Invalid Request', ['status' => 403]);
1024 }
1025
1026 try {
1027 $id = intval($request->get_param('id'));
1028
1029 if (!$id) {
1030 return new WP_Error('invalid_id', 'Invalid transaction ID', ['status' => 400]);
1031 }
1032
1033 // Check if transaction exists
1034 $transaction = DB::get_transaction($id);
1035 if (!$transaction) {
1036 return new WP_Error('transaction_not_found', 'Transaction not found', ['status' => 404]);
1037 }
1038
1039 // A pre-2.4.0 free Stripe checkout stores the form's full amount although nothing was
1040 // charged, so `Completed` would count money that never arrived. Such rows are never
1041 // rewritten; Helper's counting rule decides them (docs/features/transactions.md).
1042 if (\Better_Payment\Lite\Classes\Helper::LEGACY_FREE_CHECKOUT_STATUS === $transaction->status) {
1043 return new WP_Error('legacy_free_checkout', __('This free checkout cannot be marked as completed: its stored amount was never charged.', 'better-payment'), ['status' => 400]);
1044 }
1045
1046 // Check if transaction is already completed
1047 $completed_statuses = ['Completed', 'completed', 'Paid', 'paid', 'Success', 'success', 'Refunded', 'refunded', 'Failed', 'failed'];
1048 if (in_array($transaction->status, $completed_statuses)) {
1049 return new WP_Error('already_completed', 'Transaction is already in a completed state', ['status' => 400]);
1050 }
1051
1052 // Mark transaction as completed
1053 if (DB::mark_as_completed($id)) {
1054 return rest_ensure_response([
1055 'success' => true,
1056 'message' => __('Transaction marked as completed!', 'better-payment')
1057 ]);
1058 } else {
1059 return new WP_Error('mark_complete_failed', 'Failed to mark transaction as completed', ['status' => 500]);
1060 }
1061 } catch (\Exception $e) {
1062 return new WP_Error('mark_complete_error', $e->getMessage(), ['status' => 500]);
1063 }
1064 }
1065
1066 /**
1067 * Get transaction referer information
1068 *
1069 * @since 1.5.0
1070 * @param WP_REST_Request $request
1071 * @return WP_REST_Response|WP_Error
1072 */
1073 public function get_transaction_referer($request)
1074 {
1075 if (!$this->bp_valid_nonce($request)) {
1076 return new WP_Error('invalid_nonce', 'Invalid Request', ['status' => 403]);
1077 }
1078
1079 try {
1080 $id = intval($request->get_param('id'));
1081
1082 if (!$id) {
1083 return new WP_Error('invalid_id', 'Invalid transaction ID', ['status' => 400]);
1084 }
1085
1086 // Get transaction
1087 $transaction = DB::get_transaction($id);
1088 if (!$transaction) {
1089 return new WP_Error('transaction_not_found', 'Transaction not found', ['status' => 404]);
1090 }
1091
1092 // Extract form fields info
1093 $form_fields_info = maybe_unserialize($transaction->form_fields_info);
1094 $referer_page_id = !empty($form_fields_info['referer_page_id']) ? $form_fields_info['referer_page_id'] : '';
1095 $referer_widget_id = !empty($form_fields_info['referer_widget_id']) ? $form_fields_info['referer_widget_id'] : '';
1096
1097 // Initialize response data
1098 $referer_data = [
1099 'referer_url' => $transaction->referer,
1100 'page_title' => __('N/A', 'better-payment'),
1101 'page_link' => '#',
1102 'widget_name' => __('N/A', 'better-payment'),
1103 'widget_settings' => [],
1104 'site_logo' => '',
1105 'site_name' => ''
1106 ];
1107
1108 // Get widget settings if available
1109 if (!empty($referer_page_id) && !empty($referer_widget_id)) {
1110 $widget_settings = $this->get_elementor_widget_settings($referer_page_id, $referer_widget_id);
1111
1112 if (!empty($widget_settings)) {
1113 // Get widget name from settings
1114 $widget_name = !empty($widget_settings['form_name']) ? $widget_settings['form_name'] : __('N/A', 'better-payment');
1115 $widget_name = ($transaction->referer !== 'elementor-form' && !empty($widget_settings['better_payment_form_title']))
1116 ? $widget_settings['better_payment_form_title']
1117 : $widget_name;
1118
1119 $referer_data['widget_name'] = $widget_name;
1120 $referer_data['widget_settings'] = $widget_settings;
1121 }
1122 }
1123
1124 // Get page information if available
1125 if (!empty($referer_page_id)) {
1126 $referer_data['page_title'] = get_the_title($referer_page_id);
1127 $referer_data['page_link'] = get_permalink($referer_page_id);
1128 $referer_data['site_logo'] = wp_get_attachment_image_src(get_theme_mod('custom_logo'), 'full')[0];
1129 $referer_data['site_name'] = get_bloginfo('name');;
1130 }
1131
1132 return rest_ensure_response([
1133 'success' => true,
1134 'data' => $referer_data
1135 ]);
1136 } catch (\Exception $e) {
1137 return new WP_Error('referer_error', $e->getMessage(), ['status' => 500]);
1138 }
1139 }
1140
1141 /**
1142 * Delete transaction
1143 *
1144 * @since 1.5.0
1145 * @param WP_REST_Request $request
1146 * @return WP_REST_Response|WP_Error
1147 */
1148 public function delete_transaction($request)
1149 {
1150 if (!$this->bp_valid_nonce($request)) {
1151 return new WP_Error('invalid_nonce', 'Invalid Request', ['status' => 403]);
1152 }
1153
1154 try {
1155 $id = intval($request->get_param('id'));
1156
1157 if (!$id) {
1158 return new WP_Error('invalid_id', 'Invalid transaction ID', ['status' => 400]);
1159 }
1160
1161 // Check if transaction exists
1162 $transaction = DB::get_transaction($id);
1163 if (!$transaction) {
1164 return new WP_Error('transaction_not_found', 'Transaction not found', ['status' => 404]);
1165 }
1166
1167 // Delete the transaction
1168 if (DB::delete_transaction($id)) {
1169 return rest_ensure_response([
1170 'success' => true,
1171 'message' => __('Transaction deleted successfully', 'better-payment')
1172 ]);
1173 } else {
1174 return new WP_Error('delete_failed', 'Failed to delete transaction', ['status' => 500]);
1175 }
1176 } catch (\Exception $e) {
1177 return new WP_Error('delete_error', $e->getMessage(), ['status' => 500]);
1178 }
1179 }
1180
1181 /**
1182 * Get settings
1183 *
1184 * @since 1.5.0
1185 * @param WP_REST_Request $request
1186 * @return WP_REST_Response|WP_Error
1187 */
1188 public function get_settings($request)
1189 {
1190 if (!$this->bp_valid_nonce($request)) {
1191 return new WP_Error('invalid_nonce', 'Invalid Request', ['status' => 403]);
1192 }
1193
1194 try {
1195 $settings = DB::get_settings();
1196 return rest_ensure_response($settings);
1197 } catch (\Exception $e) {
1198 return new WP_Error('settings_error', $e->getMessage(), ['status' => 500]);
1199 }
1200 }
1201
1202 /**
1203 * Update settings
1204 *
1205 * @since 1.5.0
1206 * @param WP_REST_Request $request
1207 * @return WP_REST_Response|WP_Error
1208 */
1209 public function update_settings($request)
1210 {
1211 if (!$this->bp_valid_nonce($request)) {
1212 return new WP_Error('invalid_nonce', 'Invalid Request', ['status' => 403]);
1213 }
1214
1215 try {
1216 $settings = $request->get_json_params();
1217 $settings = $this->sanitize_settings($settings);
1218 $result = DB::update_settings($settings);
1219
1220 return rest_ensure_response([
1221 'success' => true,
1222 'message' => __('Settings updated successfully', 'better-payment'),
1223 'settings' => $result
1224 ]);
1225 } catch (\Exception $e) {
1226 return new WP_Error('settings_update_error', $e->getMessage(), ['status' => 500]);
1227 }
1228 }
1229
1230 /**
1231 * Search FluentCart products
1232 *
1233 * @since 1.5.0
1234 * @param WP_REST_Request $request
1235 * @return WP_REST_Response|WP_Error
1236 */
1237 public function search_fluentcart_products($request) {
1238 $search = $request->get_param('search');
1239
1240 if (!function_exists('fluentCart') || !class_exists('\FluentCart\App\Models\Product')) {
1241 return rest_ensure_response([]);
1242 }
1243
1244 try {
1245 $product_model = new \FluentCart\App\Models\Product();
1246
1247 $query = $product_model->newQuery()
1248 ->where('post_status', 'publish')
1249 ->limit(10);
1250
1251 if (!empty($search)) {
1252 $query->where('post_title', 'LIKE', '%' . sanitize_text_field($search) . '%');
1253 }
1254
1255 $products = $query->get();
1256 $product_list = [];
1257
1258 if (!empty($products)) {
1259 foreach ($products as $product) {
1260 $product_list[] = [
1261 'value' => strval($product->ID),
1262 'label' => $product->post_title
1263 ];
1264 }
1265 }
1266
1267 return rest_ensure_response($product_list);
1268 } catch (\Exception $e) {
1269 return rest_ensure_response([]);
1270 }
1271 }
1272
1273 /**
1274 * Get single FluentCart product
1275 *
1276 * @since 1.5.0
1277 * @param WP_REST_Request $request
1278 * @return WP_REST_Response|WP_Error
1279 */
1280 public function get_fluentcart_product($request) {
1281 $product_id = absint($request->get_param('id'));
1282
1283 if (!function_exists('fluentCart') || !class_exists('\FluentCart\App\Models\Product')) {
1284 return new WP_Error('fluentcart_not_available', 'FluentCart is not available', ['status' => 404]);
1285 }
1286
1287 try {
1288 $product = \FluentCart\App\Models\Product::with('detail', 'variants')->find($product_id);
1289
1290 if (!$product) {
1291 return new WP_Error('product_not_found', 'Product not found', ['status' => 404]);
1292 }
1293
1294 $price = '';
1295 if ($product->detail && $product->detail->min_price) {
1296 $price = strval($product->detail->min_price);
1297 } elseif ($product->variants && count($product->variants) > 0) {
1298 $price = strval($product->variants[0]->item_price);
1299 }
1300
1301 return rest_ensure_response([
1302 'name' => $product->post_title,
1303 'price' => $price,
1304 'permalink' => get_permalink($product->ID) ?: ''
1305 ]);
1306 } catch (\Exception $e) {
1307 return new WP_Error('product_error', $e->getMessage(), ['status' => 500]);
1308 }
1309 }
1310
1311 /**
1312 * Get Stripe price details
1313 *
1314 * Fetches product name and price from Stripe API using the price ID.
1315 *
1316 * @since 1.5.0
1317 * @param WP_REST_Request $request
1318 * @return WP_REST_Response|WP_Error
1319 */
1320 public function get_stripe_price($request) {
1321 if (!$this->bp_valid_nonce($request)) {
1322 return new WP_Error('invalid_nonce', 'Invalid Request', ['status' => 403]);
1323 }
1324
1325 $price_id = sanitize_text_field($request->get_param('price_id'));
1326
1327 // Validate price ID format (should start with 'price_')
1328 if (empty($price_id) || strpos($price_id, 'price_') !== 0) {
1329 return new WP_Error('invalid_price_id', 'Invalid Stripe Price ID format', ['status' => 400]);
1330 }
1331
1332 // Get Stripe settings
1333 $global_settings = get_option('better_payment_settings');
1334 $is_live_mode = !empty($global_settings['better_payment_settings_payment_stripe_live_mode'])
1335 && 'yes' === $global_settings['better_payment_settings_payment_stripe_live_mode'];
1336
1337 $secret_key = $is_live_mode
1338 ? (!empty($global_settings['better_payment_settings_payment_stripe_live_secret'])
1339 ? $global_settings['better_payment_settings_payment_stripe_live_secret'] : '')
1340 : (!empty($global_settings['better_payment_settings_payment_stripe_test_secret'])
1341 ? $global_settings['better_payment_settings_payment_stripe_test_secret'] : '');
1342
1343 if (empty($secret_key)) {
1344 return new WP_Error('missing_api_key', 'Stripe API key not configured', ['status' => 400]);
1345 }
1346
1347 // Fetch price details from Stripe using Helper trait method
1348 $price_details = $this->get_stripe_price_details($price_id, $secret_key);
1349
1350 if (empty($price_details) || isset($price_details['error'])) {
1351 $error_message = isset($price_details['error']['message'])
1352 ? $price_details['error']['message']
1353 : 'Failed to fetch Stripe price details';
1354 return new WP_Error('stripe_api_error', $error_message, ['status' => 500]);
1355 }
1356
1357 // Extract product name - need to fetch product details separately
1358 $product_name = '';
1359 if (!empty($price_details['product'])) {
1360 $product_id = $price_details['product'];
1361 $product_details = $this->get_stripe_product_details($product_id, $secret_key);
1362 $product_name = !empty($product_details['name']) ? $product_details['name'] : '';
1363 }
1364
1365 // Extract price amount and currency
1366 $amount = isset($price_details['unit_amount']) ? floatval($price_details['unit_amount']) / 100 : 0;
1367 $currency = isset($price_details['currency']) ? strtoupper($price_details['currency']) : 'USD';
1368
1369 return rest_ensure_response([
1370 'success' => true,
1371 'product_name' => $product_name,
1372 'amount' => $amount,
1373 'currency' => $currency,
1374 'formatted_price' => $currency . ' ' . number_format($amount, 2)
1375 ]);
1376 }
1377
1378 /**
1379 * Get Stripe product details
1380 *
1381 * @since 1.5.0
1382 * @param string $product_id Stripe product ID
1383 * @param string $secret_key Stripe secret key
1384 * @return array Product details or empty array
1385 */
1386 private function get_stripe_product_details($product_id, $secret_key) {
1387 if (empty($product_id) || empty($secret_key)) {
1388 return [];
1389 }
1390
1391 $api_url = 'https://api.stripe.com/v1/products/' . $product_id;
1392
1393 $response = wp_remote_get($api_url, [
1394 'headers' => [
1395 'Authorization' => 'Bearer ' . $secret_key,
1396 ],
1397 'timeout' => 20,
1398 ]);
1399
1400 if (is_wp_error($response)) {
1401 return [];
1402 }
1403
1404 $body = wp_remote_retrieve_body($response);
1405 $data = json_decode($body, true);
1406
1407 if (!isset($data) || !is_array($data)) {
1408 return [];
1409 }
1410
1411 return $data;
1412 }
1413
1414 /**
1415 * Sanitize settings
1416 *
1417 * @since 1.5.0
1418 * @param array $settings
1419 * @return array
1420 */
1421 private function sanitize_settings($settings)
1422 {
1423 // Keys whose value may legitimately contain newlines — sanitize_text_field
1424 // would collapse them. The AI system prompt is multi-line free text.
1425 $multiline_keys = apply_filters('better_payment_settings_multiline_keys', array(
1426 'better_payment_settings_ai_system_prompt',
1427 ));
1428
1429 foreach ($settings as $key => $value) {
1430 if (in_array($key, $multiline_keys, true)) {
1431 $settings[$key] = sanitize_textarea_field($value);
1432 continue;
1433 }
1434 $settings[$key] = sanitize_text_field($value);
1435 }
1436 return $settings;
1437 }
1438 }
1439