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
← All changes | includes/API/AdminAPI.php +579 -6 2.2.0 → 2.4.1 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
@@ -50,11 +55,54 @@
50 55 {
51 56 register_rest_route($this->namespace, '/transactions', [
52 57 'methods' => WP_REST_Server::READABLE,
53 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'],
54 73 'permission_callback' => [$this, 'check_admin_permissions']
55 74 ]);
56 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 +
57 105 register_rest_route($this->namespace, '/transactions/count', [
58 106 'methods' => WP_REST_Server::READABLE,
59 107 'callback' => [$this, 'get_transaction_count'],
60 108 'permission_callback' => [$this, 'check_admin_permissions']
@@ -348,8 +396,28 @@
348 396 return true;
349 397 }
350 398
351 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 + /**
352 420 * Get transactions with filters
353 421 *
354 422 * @since 1.5.0
355 423 * @param WP_REST_Request $request
@@ -363,9 +431,9 @@
363 431
364 432 try {
365 433 $paged = $request->get_param('paged') ? intval($request->get_param('paged')) : 1;
366 434 $per_page = $request->get_param('per_page') ? intval($request->get_param('per_page')) : 20;
367 - $status = $request->get_param('status') ? sanitize_text_field($request->get_param('status')) : 'all';
435 + $status = self::sanitize_transaction_status($request->get_param('status'));
368 436 $search_text = $request->get_param('search_text') ? sanitize_text_field($request->get_param('search_text')) : '';
369 437 $payment_date_from = $request->get_param('payment_date_from') ? sanitize_text_field($request->get_param('payment_date_from')) : '';
370 438 $payment_date_to = $request->get_param('payment_date_to') ? sanitize_text_field($request->get_param('payment_date_to')) : '';
371 439 $order_by = $request->get_param('order_by') ? sanitize_text_field($request->get_param('order_by')) : 'payment_date';
@@ -404,8 +472,11 @@
404 472
405 473 $transactions = DB::get_transactions($filters, 0, 'v2');
406 474
407 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');
408 479 $transactions[$key]->form_fields_info = maybe_unserialize($transaction->form_fields_info);
409 480 }
410 481 unset($filters['offset']);
411 482 $total = DB::get_transaction_count($filters, 'v2', null, $status);
@@ -422,8 +493,491 @@
422 493 }
423 494 }
424 495
425 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 + /**
426 980 * Get single transaction
427 981 *
428 982 * @since 1.5.0
429 983 * @param WP_REST_Request $request
@@ -437,18 +991,20 @@
437 991
438 992 try {
439 993 $id = $request->get_param('id');
440 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');
441 1002 $transaction->form_fields_info = maybe_unserialize($transaction->form_fields_info);
442 1003 $woo_products = maybe_unserialize($transaction->form_fields_info['detailed_product_info'] ?? []);
443 1004 $transaction->woo_products = $woo_products['woo_products'] ?? [];
444 1005 $transaction->woo_products = array_values($transaction->woo_products);
445 1006
446 -
447 - if (!$transaction) {
448 - return new WP_Error('transaction_not_found', 'Transaction not found', ['status' => 404]);
449 - }
450 -
451 1007 return rest_ensure_response($transaction);
452 1008 } catch (\Exception $e) {
453 1009 return new WP_Error('transaction_error', $e->getMessage(), ['status' => 500]);
454 1010 }
@@ -479,8 +1035,15 @@
479 1035 if (!$transaction) {
480 1036 return new WP_Error('transaction_not_found', 'Transaction not found', ['status' => 404]);
481 1037 }
482 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 +
483 1046 // Check if transaction is already completed
484 1047 $completed_statuses = ['Completed', 'completed', 'Paid', 'paid', 'Success', 'success', 'Refunded', 'refunded', 'Failed', 'failed'];
485 1048 if (in_array($transaction->status, $completed_statuses)) {
486 1049 return new WP_Error('already_completed', 'Transaction is already in a completed state', ['status' => 400]);
@@ -856,9 +1419,19 @@
856 1419 * @return array
857 1420 */
858 1421 private function sanitize_settings($settings)
859 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 +
860 1429 foreach ($settings as $key => $value) {
1430 + if (in_array($key, $multiline_keys, true)) {
1431 + $settings[$key] = sanitize_textarea_field($value);
1432 + continue;
1433 + }
861 1434 $settings[$key] = sanitize_text_field($value);
862 1435 }
863 1436 return $settings;
864 1437 }