• File: SubscriptionController.php
  • Full Path: /home/bravrvjk/itiministry.org/wp-content/plugins/give/src/API/REST/V3/Routes/Subscriptions/SubscriptionController.php
  • Date Modified: 11/05/2025 7:00 PM
  • File size: 24.4 KB
  • MIME-type: text/x-php
  • Charset: utf-8
<?php

namespace Give\API\REST\V3\Routes\Subscriptions;

use Exception;
use Give\API\REST\V3\Routes\Donors\ValueObjects\DonorAnonymousMode;
use Give\API\REST\V3\Routes\Subscriptions\Actions\GetSubscriptionCollectionParams;
use Give\API\REST\V3\Routes\Subscriptions\Actions\GetSubscriptionItemSchema;
use Give\API\REST\V3\Routes\Subscriptions\Actions\GetSubscriptionSharedParamsForGetMethods;
use Give\API\REST\V3\Routes\Subscriptions\DataTransferObjects\SubscriptionCreateData;
use Give\API\REST\V3\Routes\Subscriptions\Exceptions\SubscriptionValidationException;
use Give\API\REST\V3\Routes\Subscriptions\Fields\SubscriptionFields;
use Give\API\REST\V3\Routes\Subscriptions\Permissions\SubscriptionPermissions;
use Give\API\REST\V3\Routes\Subscriptions\ValueObjects\SubscriptionRoute;
use Give\API\REST\V3\Support\CURIE;
use Give\API\REST\V3\Support\Headers;
use Give\API\REST\V3\Support\Item;
use Give\Subscriptions\Models\Subscription;
use Give\Subscriptions\SubscriptionQuery;
use Give\Subscriptions\ValueObjects\SubscriptionStatus;
use Give\Subscriptions\ViewModels\SubscriptionViewModel;
use WP_Error;
use WP_REST_Controller;
use WP_REST_Request;
use WP_REST_Response;
use WP_REST_Server;

/**
 * The methods using snake case like register_routes() are present in the base class,
 * and the methods using camel case like deleteItems() are available only on this class.
 *
 * @since 4.8.0
 */
class SubscriptionController extends WP_REST_Controller
{
    /**
     * @var string
     */
    protected $namespace;

    /**
     * @var string
     */
    protected $rest_base;

    /**
     * @since 4.8.0
     */
    public function __construct()
    {
        $this->namespace = SubscriptionRoute::NAMESPACE;
        $this->rest_base = SubscriptionRoute::BASE;
    }

    /**
     * @since 4.9.0 Move schema key to the route level instead of defining it for each endpoint (which is incorrect)
     * @since 4.8.0
     */
    public function register_routes()
    {
        register_rest_route($this->namespace, '/' . $this->rest_base, [
            [
                'methods' => WP_REST_Server::READABLE,
                'callback' => [$this, 'get_items'],
                'permission_callback' => [$this, 'get_items_permissions_check'],
                'args' => array_merge($this->get_collection_params(), give(GetSubscriptionSharedParamsForGetMethods::class)()),
            ],
            [
                'methods' => WP_REST_Server::CREATABLE,
                'callback' => [$this, 'create_item'],
                'permission_callback' => [$this, 'create_item_permissions_check'],
                'args' => rest_get_endpoint_args_for_schema($this->get_item_schema(), WP_REST_Server::CREATABLE),
            ],
            [
                'methods' => WP_REST_Server::DELETABLE,
                'callback' => [$this, 'deleteItems'],
                'permission_callback' => [$this, 'delete_items_permissions_check'],
                'args' => [
                    'ids' => [
                        'description' => __('Array of subscription IDs to delete', 'give'),
                        'type' => 'array',
                        'items' => [
                            'type' => 'integer',
                        ],
                        'required' => true,
                    ],
                    'force' => [
                        'description' => __('Whether to permanently delete (force=true) or move to trash (force=false, default).', 'give'),
                        'type' => 'boolean',
                        'default' => false,
                    ],
                ],
            ],
            'schema' => [$this, 'get_public_item_schema'],
        ]);

        register_rest_route($this->namespace, '/' . $this->rest_base . '/(?P<id>[\d]+)', [
            [
                'methods' => WP_REST_Server::READABLE,
                'callback' => [$this, 'get_item'],
                'permission_callback' => [$this, 'get_item_permissions_check'],
                'args' => array_merge([
                    'id' => [
                        'description' => __('The subscription ID.', 'give'),
                        'type' => 'integer',
                        'required' => true,
                    ],
                    '_embed' => [
                        'description' => __(
                            'Whether to embed related resources in the response. It can be true when we want to embed all available resources, or a string like "givewp:donor" when we wish to embed only a specific one.',
                            'give'
                        ),
                        'type' => [
                            'string',
                            'boolean',
                        ],
                        'default' => false,
                    ],
                ], give(GetSubscriptionSharedParamsForGetMethods::class)()),
            ],
            [
                'methods' => WP_REST_Server::EDITABLE,
                'callback' => [$this, 'update_item'],
                'permission_callback' => [$this, 'update_item_permissions_check'],
                'args' => rest_get_endpoint_args_for_schema($this->get_item_schema(), WP_REST_Server::EDITABLE),
            ],
            [
                'methods' => WP_REST_Server::DELETABLE,
                'callback' => [$this, 'delete_item'],
                'permission_callback' => [$this, 'delete_item_permissions_check'],
                'args' => [
                    'id' => [
                        'description' => __('The subscription ID.', 'give'),
                        'type' => 'integer',
                        'required' => true,
                    ],
                    'force' => [
                        'description' => __('Whether to permanently delete (force=true) or move to trash (force=false, default).', 'give'),
                        'type' => 'boolean',
                        'default' => false,
                    ],
                ],
            ],
            'schema' => [$this, 'get_public_item_schema'],
        ]);

        register_rest_route($this->namespace, '/' . $this->rest_base . '/(?P<id>[\d]+)/cancel', [
            [
                'methods' => WP_REST_Server::EDITABLE,
                'callback' => [$this, 'cancel_item'],
                'permission_callback' => [$this, 'cancel_item_permissions_check'],
                'args' => [
                    'id' => [
                        'type' => 'integer',
                        'required' => true,
                    ],
                    'trash' => [
                        'type' => 'boolean',
                        'default' => false,
                        'description' => __('Whether to also move the subscription to trash (trash=true) instead of just canceling it.', 'give'),
                    ],
                ],
                'schema' => [$this, 'get_public_item_schema'],
            ],
        ]);
    }

    /**
     * Get subscriptions.
     *
     * @since 4.8.0
     *
     * @param WP_REST_Request $request Full data about the request.
     *
     * @return WP_REST_Response|WP_Error Response object on success, or WP_Error object on failure.
     *
     * @throws Exception
     */
    public function get_items($request)
    {
        $page = $request->get_param('page');
        $perPage = $request->get_param('per_page');
        $sortColumn = $this->getSortColumn($request->get_param('sort'));
        $sortDirection = $request->get_param('direction');
        $mode = $request->get_param('mode');
        $status = $request->get_param('status');
        $includeSensitiveData = $request->get_param('includeSensitiveData');
        $donorAnonymousMode = new DonorAnonymousMode($request->get_param('anonymousDonors'));

        $query = new SubscriptionQuery();
        $query->whereMode($mode);

        if ($campaignId = $request->get_param('campaignId')) {
            $query->whereCampaignId($campaignId);
        }

        if ($donorAnonymousMode->isExcluded()) {
            $query->excludeAnonymousDonors();
        }

        if ($donorId = $request->get_param('donorId')) {
            $query->whereDonorId($donorId);
        }

        if (!in_array('any', (array) $status, true)) {
            $query->whereStatus((array)$status);
        }

        if (in_array($sortColumn, ['firstName', 'lastName'], true)) {
            $query->selectDonorNames();
        }

        $totalQuery = $query->clone();
        $query->limit($perPage)->offset(($page - 1) * $perPage)->orderBy($sortColumn, $sortDirection);

        $subscriptions = $query->getAll() ?? [];

        $subscriptions = array_map(function ($subscription) use ($donorAnonymousMode, $includeSensitiveData, $request) {
            $item = (new SubscriptionViewModel($subscription))
                ->anonymousMode($donorAnonymousMode)
                ->includeSensitiveData($includeSensitiveData)
                ->exports();

            return $this->prepare_response_for_collection(
                $this->prepare_item_for_response($item, $request)
            );
        }, $subscriptions);

        $totalSubscriptions = empty($subscriptions) ? 0 : $totalQuery->count();
        $response = rest_ensure_response($subscriptions);
        $response = Headers::addPagination($response, $request, $totalSubscriptions, $perPage, $this->rest_base);

        return $response;
    }

    /**
     * Get a subscription.
     *
     * @since 4.8.0
     *
     * @param WP_REST_Request $request Full data about the request.
     *
     * @return WP_REST_Response|WP_Error Response object on success, or WP_Error object on failure.
     *
     * @throws Exception
     */
    public function get_item($request)
    {
        $subscription = Subscription::find($request->get_param('id'));

        if (!$subscription) {
            return new WP_Error('subscription_not_found', __('Subscription not found', 'give'), ['status' => 404]);
        }

        $includeSensitiveData = $request->get_param('includeSensitiveData');
        $donorAnonymousMode = new DonorAnonymousMode($request->get_param('anonymousDonors'));

        $item = (new SubscriptionViewModel($subscription))
            ->anonymousMode($donorAnonymousMode)
            ->includeSensitiveData($includeSensitiveData)
            ->exports();

        $response = $this->prepare_item_for_response($item, $request);

        return rest_ensure_response($response);
    }

    /**
     * Create a subscription.
     *
     * @since 4.8.0
     *
     * @param WP_REST_Request $request Full data about the request.
     *
     * @return WP_REST_Response|WP_Error Response object on success, or WP_Error object on failure.
     *
     * @throws Exception
     */
    public function create_item($request)
    {
        try {
            $data = SubscriptionCreateData::fromRequest($request);
            $subscription = $data->createSubscription();

            $fieldsUpdate = $this->update_additional_fields_for_object($subscription, $request);

            if (is_wp_error($fieldsUpdate)) {
                return $fieldsUpdate;
            }
        } catch (SubscriptionValidationException $e) {
            return new WP_REST_Response([
                'message' => $e->getMessage(),
                'error' => $e->getErrorCode()
            ], $e->getStatusCode());
        } catch (Exception $e) {
            return new WP_REST_Response([
                'message' => sprintf(__('Failed to create subscription: %s', 'give'), $e->getMessage()),
                'error' => 'internal_server_error'
            ], 500);
        }

        $item = (new SubscriptionViewModel($subscription))
            ->includeSensitiveData(true)
            ->exports();

        $response = $this->prepare_item_for_response($item, $request);
        $response->set_status(201);

        return rest_ensure_response($response);
    }

    /**
     * Update a subscription.
     *
     * @since 4.11.0 Exclude gatewaySubscriptionId from non-editable fields
     * @since 4.8.0
     *
     * @param WP_REST_Request $request Full data about the request.
     *
     * @return WP_REST_Response|WP_Error Response object on success, or WP_Error object on failure.
     *
     * @throws Exception
     */
    public function update_item($request)
    {
        $subscription = Subscription::find($request->get_param('id'));

        if (!$subscription) {
            return new WP_REST_Response(__('Subscription not found', 'give'), 404);
        }

        $nonEditableFields = [
            'id',
            'createdAt',
            'mode',
            'gatewayId',
        ];

        foreach ($request->get_params() as $key => $value) {
            if (!in_array($key, $nonEditableFields, true)) {
                if (in_array($key, $subscription::propertyKeys(), true)) {
                    try {
                        $processedValue = SubscriptionFields::processValue($key, $value);
                        if ($subscription->isPropertyTypeValid($key, $processedValue)) {
                            $subscription->$key = $processedValue;
                        }
                    } catch (Exception $e) {
                        continue;
                    }
                }
            }
        }

        if ($subscription->isDirty()) {
            $subscription->save();
        }

        $fieldsUpdate = $this->update_additional_fields_for_object($subscription, $request);

        if (is_wp_error($fieldsUpdate)) {
            return $fieldsUpdate;
        }

        $item = (new SubscriptionViewModel($subscription))->includeSensitiveData(true)->exports();

        $response = $this->prepare_item_for_response($item, $request);

        return rest_ensure_response($response);
    }

    /**
     * Delete a subscription.
     *
     * @since 4.8.0
     *
     * @param WP_REST_Request $request Full data about the request.
     *
     * @return WP_REST_Response|WP_Error Response object on success, or WP_Error object on failure.
     *
     * @throws Exception
     */
    public function delete_item($request): WP_REST_Response
    {
        $subscription = Subscription::find($request->get_param('id'));
        $force = $request->get_param('force');

        if (!$subscription) {
            return new WP_REST_Response(['message' => __('Subscription not found', 'give')], 404);
        }

        $item = (new SubscriptionViewModel($subscription))->exports();

        if ($force) { // Permanently delete the subscription
            $deleted = $subscription->delete();

            if (!$deleted) {
                return new WP_REST_Response(['message' => __('Failed to delete subscription', 'give')], 500);
            }
        } else { // Move the subscription to trash (soft delete)
            $trashed = $subscription->trash();

            if (!$trashed) {
                return new WP_REST_Response(['message' => __('Failed to trash subscription', 'give')], 500);
            }
        }

        return new WP_REST_Response(['deleted' => true, 'previous' => $item], 200);
    }

    /**
     * Delete multiple subscriptions.
     *
     * @since 4.8.0
     *
     * @param WP_REST_Request $request Full data about the request.
     *
     * @return WP_REST_Response|WP_Error Response object on success, or WP_Error object on failure.
     *
     * @throws Exception
     */
    public function deleteItems($request): WP_REST_Response
    {
        $ids = $request->get_param('ids');
        $force = $request->get_param('force');
        $deleted = [];
        $errors = [];

        foreach ($ids as $id) {
            $subscription = Subscription::find($id);

            if (!$subscription) {
                $errors[] = ['id' => $id, 'message' => __('Subscription not found', 'give')];

                continue;
            }

            $item = (new SubscriptionViewModel($subscription))->exports();

            if ($force) {
                if ($subscription->delete()) {
                    $deleted[] = ['id' => $id, 'previous' => $item];
                } else {
                    $errors[] = ['id' => $id, 'message' => __('Failed to delete subscription', 'give')];
                }
            } else {
                $trashed = $subscription->trash();

                if ($trashed) {
                    $deleted[] = ['id' => $id, 'previous' => $item];
                } else {
                    $errors[] = ['id' => $id, 'message' => __('Failed to trash subscription', 'give')];
                }
            }
        }

        return new WP_REST_Response([
            'deleted' => $deleted,
            'errors' => $errors,
            'total_requested' => count($ids),
            'total_deleted' => count($deleted),
            'total_errors' => count($errors),
        ], 200);
    }

    /**
     * Cancel a subscription.
     *
     * @since 4.8.0
     */
    public function cancel_item($request)
    {
        $subscription = Subscription::find($request->get_param('id'));

        if (!$subscription) {
            return new WP_REST_Response(__('Subscription not found', 'give'), 404);
        }

        try {
            if (give()->gateways->hasPaymentGateway($subscription->gatewayId)) {
                $subscription->cancel(true);
            } else {
                $subscription->status = SubscriptionStatus::CANCELLED();
                $subscription->save();
            }

            $trash = $request->get_param('trash');

            if ($trash) {
                $subscription->trash();
            }

            $item = (new SubscriptionViewModel($subscription))->includeSensitiveData(true)->exports();
            $response = $this->prepare_item_for_response($item, $request);

            return rest_ensure_response($response);
        } catch (Exception $e) {
            return new WP_REST_Response(__('Failed to cancel subscription', 'give'), 500);
        }
    }

    /**
     * @since 4.8.0
     */
    public function getSortColumn(string $sortColumn): string
    {
        $sortColumnsMap = [
            'id' => 'id',
            'createdAt' => 'created',
            'renewsAt' => 'expiration',
            'status' => 'status',
            'amount' => 'recurring_amount',
            'feeAmountRecovered' => 'recurring_fee_amount',
            'donorId' => 'customer_id',
            'firstName' => 'firstName',
            'lastName' => 'lastName',
        ];

        return $sortColumnsMap[$sortColumn] ?? 'id';
    }

    /**
     * @since 4.8.0
     */
    public function get_collection_params(): array
    {
        $params = parent::get_collection_params();

        $params['page']['default'] = 1;
        $params['per_page']['default'] = 30;

        // Remove default parameters not being used
        unset($params['context']);
        unset($params['search']);

        $params += give(GetSubscriptionCollectionParams::class)();

        return $params;
    }

    /**
     * @since 4.13.0 added anonymousDonors and includeSensitiveData to embeddable links
     * @since 4.10.0 added embeddable links for campaign and form
     * @since 4.8.0
     *
     * @param mixed           $item    WordPress representation of the item.
     * @param WP_REST_Request $request Request object.
     *
     * @return WP_REST_Response|WP_Error Response object on success, or WP_Error object on failure.
     */
    public function prepare_item_for_response($item, $request)
    {
        try {
            $subscriptionId = $request->get_param('id') ?? $item['id'] ?? null;

            if ($subscriptionId && $subscription = Subscription::find($subscriptionId)) {
                $self_url = rest_url(sprintf('%s/%s/%d', $this->namespace, $this->rest_base, $subscription->id));

                $links = [
                    'self' => ['href' => $self_url]
                ];

                if (!empty($item['donorId'])) {
                    $donor_url = rest_url(sprintf('%s/%s/%d', $this->namespace, 'donors', $item['donorId']));
                    $donor_url = add_query_arg([
                        'mode' => $request->get_param('mode'),
                        'anonymousDonors' => $request->get_param('anonymousDonors'),
                        'includeSensitiveData' => $request->get_param('includeSensitiveData'),
                    ], $donor_url);

                    $links[CURIE::relationUrl('donor')] = [
                        'href' => $donor_url,
                        'embeddable' => true,
                    ];
                }

                if (!empty($item['donationFormId'])) {
                    $form_url = rest_url(sprintf('%s/%s/%d', $this->namespace, 'forms', $item['donationFormId']));
                    $form_url = add_query_arg([
                        'mode' => $subscription->mode->getValue(),
                    ], $form_url);

                    $links[CURIE::relationUrl('form')] = [
                        'href' => $form_url,
                        'embeddable' => true,
                    ];
                }

                if (!empty($item['campaignId'])) {
                    $campaign_url = rest_url(sprintf('%s/%s/%d', $this->namespace, 'campaigns', $item['campaignId']));
                    $campaign_url = add_query_arg([
                        'mode' => $subscription->mode->getValue(),
                    ], $campaign_url);

                    $links[CURIE::relationUrl('campaign')] = [
                        'href' => $campaign_url,
                        'embeddable' => true,
                    ];
                }

                $donations_url = rest_url(sprintf('%s/%s', $this->namespace, 'donations'));
                $donations_url = add_query_arg([
                    'mode' => $subscription->mode->getValue(),
                    'subscriptionId' => $subscription->id,
                    'anonymousDonations' => $request->get_param('anonymousDonors'),
                    'includeSensitiveData' => $request->get_param('includeSensitiveData'),
                ], $donations_url);

                $links[CURIE::relationUrl('donations')] = [
                    'href' => $donations_url,
                    'embeddable' => true,
                ];
            } else {
                $links = [];
            }

            $response = new WP_REST_Response(Item::formatDatesForResponse($item, ['createdAt', 'renewsAt']));
            if (!empty($links)) {
                $response->add_links($links);
            }

            $response->data = $this->add_additional_fields_to_object($response->data, $request);

            return $response;
        } catch (Exception $e) {
            return new WP_Error(
                'prepare_item_for_response_error',
                sprintf(
                    __('Error while preparing subscription for response: %s', 'give'),
                    $e->getMessage()
                ),
                ['status' => 400]
            );
        }
    }

    /**
     * @since 4.8.0
     *
     * @param WP_REST_Request $request
     *
     * @return true|WP_Error
     */
    public function get_items_permissions_check($request)
    {
        return SubscriptionPermissions::validationForGetMethods($request);
    }

    /**
     * @since 4.8.0
     *
     * @param WP_REST_Request $request
     *
     * @return true|WP_Error
     */
    public function get_item_permissions_check($request)
    {
        return SubscriptionPermissions::validationForGetMethods($request);
    }

    /**
     * @since 4.8.0
     *
     * @param WP_REST_Request $request
     *
     * @return true|WP_Error
     */
    public function update_item_permissions_check($request)
    {
        return SubscriptionPermissions::validationForUpdateMethod($request);
    }

    /**
     * @since 4.8.0
     *
     * @param WP_REST_Request $request
     *
     * @return true|WP_Error
     */
    public function create_item_permissions_check($request)
    {
        return SubscriptionPermissions::validationForUpdateMethod($request);
    }

    /**
     * @since 4.8.0
     *
     * @param WP_REST_Request $request
     *
     * @return true|WP_Error
     */
    public function delete_item_permissions_check($request)
    {
        return SubscriptionPermissions::validationForDeleteMethods($request);
    }

    /**
     * @since 4.8.0
     *
     * @param WP_REST_Request $request
     *
     * @return true|WP_Error
     */
    public function delete_items_permissions_check($request)
    {
        return SubscriptionPermissions::validationForDeleteMethods($request);
    }

    /**
     * @since 4.8.0
     */
    public function cancel_item_permissions_check($request)
    {
        return SubscriptionPermissions::validationForDeleteMethods($request);
    }

    /**
     * @since 4.8.0
     */
    public function get_item_schema(): array
    {
        $schema = give(GetSubscriptionItemSchema::class)();
        return $this->add_additional_fields_schema($schema);
    }
}