PluginProbe
Better Payment – Instant Payments, Donations, Fundraising with Subscriptions & More / 2.3.4
Better Payment – Instant Payments, Donations, Fundraising with Subscriptions & More v2.3.4
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
← All changes | includes/API/AdminAPI.php +533 -0 2.2.22.3.4 View file →
@@ -2,11 +2,16 @@
2 2
3 3 namespace Better_Payment\Lite\API;
4 4
5 5 use Better_Payment\Lite\Admin\DB;
6 +use Better_Payment\Lite\Admin\SubscriptionListFilter;
7 +use Better_Payment\Lite\Models\SubscriptionRelationModel;
6 8 use Better_Payment\Lite\Traits\Helper;
9 +use Better_Payment\Lite\WooCommerce\Subscriptions as WooSubscriptions;
7 10 use WP_Error;
8 11 use WP_REST_Controller;
12 +use WP_REST_Request;
13 +use WP_REST_Response;
9 14 use WP_REST_Server;
10 15
11 16 /**
12 17 * Exit if accessed directly
@@ -53,8 +58,43 @@
53 58 'callback' => [$this, 'get_transactions'],
54 59 'permission_callback' => [$this, 'check_admin_permissions']
55 60 ]);
56 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 +
57 97 register_rest_route($this->namespace, '/transactions/count', [
58 98 'methods' => WP_REST_Server::READABLE,
59 99 'callback' => [$this, 'get_transaction_count'],
60 100 'permission_callback' => [$this, 'check_admin_permissions']
@@ -422,8 +462,491 @@
422 462 }
423 463 }
424 464
425 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 + /**
426 949 * Get single transaction
427 950 *
428 951 * @since 1.5.0
429 952 * @param WP_REST_Request $request
@@ -856,9 +1379,19 @@
856 1379 * @return array
857 1380 */
858 1381 private function sanitize_settings($settings)
859 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 +
860 1389 foreach ($settings as $key => $value) {
1390 + if (in_array($key, $multiline_keys, true)) {
1391 + $settings[$key] = sanitize_textarea_field($value);
1392 + continue;
1393 + }
861 1394 $settings[$key] = sanitize_text_field($value);
862 1395 }
863 1396 return $settings;
864 1397 }