PluginProbe
Better Payment – Instant Payments, Donations, Fundraising with Subscriptions & More / 2.3.3
Better Payment – Instant Payments, Donations, Fundraising with Subscriptions & More v2.3.3
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 1.0.5 1.0.6 All 66 releases
better-payment / includes / API / AdminAPI.php

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

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