• File: DonationController.php
  • Full Path: /home/bravrvjk/itiministry.org/wp-content/plugins/give/src/API/REST/V3/Routes/Subscriptions/ValueObjects/DonationController.php
  • Date Modified: 01/28/2026 8:00 PM
  • File size: 46.83 KB
  • MIME-type: text/x-php
  • Charset: utf-8
<?php

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

use Exception;
use Give\API\REST\V3\Routes\Donations\DataTransferObjects\DonationCreateData;
use Give\API\REST\V3\Routes\Donations\Exceptions\DonationValidationException;
use Give\API\REST\V3\Routes\Donations\Fields\DonationFields;
use Give\API\REST\V3\Routes\Donations\ValueObjects\DonationAnonymousMode;
use Give\API\REST\V3\Routes\Donations\ValueObjects\DonationRoute;
use Give\API\REST\V3\Support\CURIE;
use Give\API\REST\V3\Support\Item;
use Give\API\REST\V3\Support\Schema\SchemaTypes;
use Give\Donations\Models\Donation;
use Give\Donations\ValueObjects\DonationMode;
use Give\Donations\ValueObjects\DonationStatus;
use Give\Donations\ValueObjects\DonationType;
use Give\API\REST\V3\Routes\Donations\ViewModels\DonationViewModel;
use Give\Framework\PaymentGateways\CommandHandlers\PaymentRefundedHandler;
use Give\Framework\PaymentGateways\Commands\PaymentRefunded;
use Give\Framework\PaymentGateways\Contracts\PaymentGatewayRefundable;
use Give\Framework\Permissions\Facades\UserPermissions;
use WP_Error;
use WP_REST_Controller;
use WP_REST_Request;
use WP_REST_Response;
use WP_REST_Server;

/**
 * @since 4.6.0
 */
class DonationController extends WP_REST_Controller
{
    /**
     * @var string
     */
    protected $namespace;

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

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

    /**
     *
     * @since 4.14.0 replaced permissionsCheck with get_item_permissions_check and get_items_permissions_check
     * @since 4.9.0 Move schema key to the route level instead of defining it for each endpoint (which is incorrect)
     * @since 4.6.0
     */
    public function register_routes()
    {
        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' => [
                    '_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,
                    ],
                    'id' => [
                        'type' => 'integer',
                        'required' => true,
                    ],
                    'includeSensitiveData' => [
                        'type' => 'boolean',
                        'default' => false,
                    ],
                    'anonymousDonations' => [
                        'type' => 'string',
                        'default' => 'exclude',
                        'enum' => [
                            'exclude',
                            'include',
                            'redact',
                        ],
                    ],
                ],
            ],
            [
                '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' => [
                        'type' => 'integer',
                        'required' => true,
                    ],
                    'force' => [
                        'type' => 'boolean',
                        'default' => false,
                        'description' => 'Whether to permanently delete (force=true) or move to trash (force=false, default).',
                    ],
                ],
            ],
            'schema' => [$this, 'get_public_item_schema'],
        ]);

        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' => $this->get_collection_params(),
            ],
            [
                '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, 'delete_items'],
                'permission_callback' => [$this, 'delete_items_permissions_check'],
                'args' => [
                    'ids' => [
                        'description' => __('Array of donation IDs to delete', 'give'),
                        'type' => 'array',
                        'items' => [
                            'type' => 'integer',
                        ],
                        'required' => true,
                    ],
                    'force' => [
                        'type' => 'boolean',
                        'default' => false,
                        'description' => 'Whether to permanently delete (force=true) or move to trash (force=false, default).',
                    ],
                ],
            ],
            'schema' => [$this, 'get_public_item_schema'],
        ]);

        register_rest_route($this->namespace, '/' . $this->rest_base . '/(?P<id>[\d]+)/refund', [
            [
                'methods' => WP_REST_Server::EDITABLE,
                'callback' => [$this, 'refund_item'],
                'permission_callback' => [$this, 'refund_item_permissions_check'],
                'args' => [
                    'id' => [
                        'type' => 'integer',
                        'required' => true,
                    ],
                ],
            ],
            'schema' => [$this, 'get_public_item_schema'],
        ]);
    }

    /**
     * @since 4.6.0
     */
    public function get_items($request)
    {
        $includeSensitiveData = $request->get_param('includeSensitiveData');
        $donationAnonymousMode = new DonationAnonymousMode($request->get_param('anonymousDonations'));
        $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');

        $query = Donation::query();

        if ($campaignId = $request->get_param('campaignId')) {
            // Filter by CampaignId
            $query->where('give_donationmeta_attach_meta_campaignId.meta_value', $campaignId);
        }

        if ($donorId = $request->get_param('donorId')) {
            $query->where('give_donationmeta_attach_meta_donorId.meta_value', $donorId);
        }

        if ($subscriptionId = $request->get_param('subscriptionId')) {
            $query->where('give_donationmeta_attach_meta_subscriptionId.meta_value', $subscriptionId);
        }

        if ($donationAnonymousMode->isExcluded()) {
            // Exclude anonymous donations from results
            $query->where('give_donationmeta_attach_meta_anonymous.meta_value', 0);
        }

        // Include only current payment "mode"
        $query->where('give_donationmeta_attach_meta_mode.meta_value', $mode);

        // Filter by status if not 'any'
        if (!in_array('any', (array)$status, true)) {
            $query->whereIn('post_status', (array)$status);
        }

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

        $donations = $query->getAll() ?? [];
        $donations = array_map(function ($donation) use ($includeSensitiveData, $donationAnonymousMode, $request) {
            $item = (new DonationViewModel($donation))
                ->anonymousMode($donationAnonymousMode)
                ->includeSensitiveData($includeSensitiveData)
                ->exports();

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

        $totalDonations = empty($donations) ? 0 : Donation::query()->count();
        $totalPages = (int)ceil($totalDonations / $perPage);

        $response = rest_ensure_response($donations);
        $response->header('X-WP-Total', $totalDonations);
        $response->header('X-WP-TotalPages', $totalPages);

        $base = add_query_arg(
            map_deep($request->get_query_params(), function ($value) {
                if (is_bool($value)) {
                    $value = $value ? 'true' : 'false';
                }

                return urlencode($value);
            }),
            rest_url(DonationRoute::BASE)
        );

        if ($page > 1) {
            $prevPage = $page - 1;

            if ($prevPage > $totalPages) {
                $prevPage = $totalPages;
            }

            $response->link_header('prev', add_query_arg('page', $prevPage, $base));
        }

        if ($totalPages > $page) {
            $nextPage = $page + 1;
            $response->link_header('next', add_query_arg('page', $nextPage, $base));
        }

        return $response;
    }

    /**
     * @since 4.6.0
     */
    public function get_item($request)
    {
        $donation = Donation::find($request->get_param('id'));
        $includeSensitiveData = $request->get_param('includeSensitiveData');
        $donationAnonymousMode = new DonationAnonymousMode($request->get_param('anonymousDonations'));

        if (!$donation || ($donation->anonymous && $donationAnonymousMode->isExcluded())) {
            return new WP_Error('donation_not_found', __('Donation not found', 'give'), ['status' => 404]);
        }

        $item = (new DonationViewModel($donation))
            ->anonymousMode($donationAnonymousMode)
            ->includeSensitiveData($includeSensitiveData)
            ->exports();

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

        return rest_ensure_response($response);
    }

    /**
     * Create a single donation.
     *
     * @since 4.8.0
     */
    public function create_item($request): WP_REST_Response
    {
        try {
            $data = DonationCreateData::fromRequest($request);
            $donation = $data->isRenewal() ? $data->createRenewal() : $data->createDonation();

            $item = (new DonationViewModel($donation))
                ->includeSensitiveData(true)
                ->anonymousMode(new DonationAnonymousMode('include'))
                ->exports();

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

            return rest_ensure_response($response);
        } catch (DonationValidationException $e) {
            return new WP_REST_Response([
                'message' => $e->getMessage(),
                'error' => $e->getErrorCode()
            ], $e->getStatusCode());
        } catch (\Exception $e) {
            return new WP_REST_Response([
                'message' => __('Failed to create donation', 'give'),
                'error' => $e->getMessage()
            ], 400);
        }
    }

    /**
     * Update a single donation.
     *
     * @since 4.7.0 Add support for updating custom fields
     * @since 4.6.0
     *
     * @return WP_REST_Response|WP_Error
     */
    public function update_item($request)
    {
        $donation = Donation::find($request->get_param('id'));

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

        $nonEditableFields = [
            'id',
            'updatedAt',
            'purchaseKey',
            'donorIp',
            'type',
            'mode',
            'gatewayTransactionId',
        ];

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

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

        $item = (new DonationViewModel($donation))
            ->includeSensitiveData(true)
            ->anonymousMode(new DonationAnonymousMode('include'))
            ->exports();

        $fieldsUpdate = $this->update_additional_fields_for_object($item, $request);
        if (is_wp_error($fieldsUpdate)) {
            return $fieldsUpdate;
        }

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

        return rest_ensure_response($response);
    }

    /**
     * Refund a single donation.
     *
     * @since 4.6.0
     */
    public function refund_item($request)
    {
        $donation = Donation::find($request->get_param('id'));

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

        $gateway = $donation->gateway();

        if (!$gateway->supportsRefund()) {
            return new WP_REST_Response(__('Refunds are not supported for this gateway', 'give'), 400);
        }

        try {
            /** @var PaymentGatewayRefundable $gateway */
            $command = $gateway->refundDonation($donation);

            if ($command instanceof PaymentRefunded) {
                $handler = new PaymentRefundedHandler($command);
                $handler->handle($donation);
            }

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

            return rest_ensure_response($response);
        } catch (\Exception $exception) {
            return new WP_REST_Response(__('Failed to refund donation', 'give'), 500);
        }
    }

    /**
     * Delete a single donation.
     *
     * @since 4.6.0
     */
    public function delete_item($request): WP_REST_Response
    {
        $donation = Donation::find($request->get_param('id'));
        $force = $request->get_param('force');

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

        $item = (new DonationViewModel($donation))
            ->includeSensitiveData(true)
            ->anonymousMode(new DonationAnonymousMode('include'))
            ->exports();

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

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

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

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

    /**
     * Delete multiple donations.
     *
     * @since 4.6.0
     */
    public function delete_items($request): WP_REST_Response
    {
        $ids = $request->get_param('ids');
        $force = $request->get_param('force');
        $deleted = [];
        $errors = [];

        foreach ($ids as $id) {
            $donation = Donation::find($id);

            if (!$donation) {
                $errors[] = ['id' => $id, 'message' => __('Donation not found', 'give')];
                continue;
            }

            $item = (new DonationViewModel($donation))
                ->includeSensitiveData(true)
                ->anonymousMode(new DonationAnonymousMode('include'))
                ->exports();

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

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

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

    /**
     * @since 4.13.0 updated the amount sort columns to CAST as DECIMAL
     * @since 4.6.0
     */
    public function getSortColumn(string $sortColumn): string
    {
        $sortColumnsMap = [
            'id' => 'ID',
            'createdAt' => 'post_date',
            'updatedAt' => 'post_modified',
            'status' => 'post_status',
            'amount' => 'CAST(give_donationmeta_attach_meta_amount.meta_value AS DECIMAL(10, 2))',
            'feeAmountRecovered' => 'CAST(give_donationmeta_attach_meta_feeAmountRecovered.meta_value AS DECIMAL(10, 2))',
            'donorId' => 'give_donationmeta_attach_meta_donorId.meta_value',
            'firstName' => 'give_donationmeta_attach_meta_firstName.meta_value',
            'lastName' => 'give_donationmeta_attach_meta_lastName.meta_value',
        ];

        return $sortColumnsMap[$sortColumn];
    }

    /**
     * @since 4.6.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 += [
            'sort' => [
                'type' => 'string',
                'default' => 'id',
                'enum' => [
                    'id',
                    'createdAt',
                    'updatedAt',
                    'status',
                    'amount',
                    'feeAmountRecovered',
                    'donorId',
                    'firstName',
                    'lastName',
                ],
            ],
            'direction' => [
                'type' => 'string',
                'default' => 'DESC',
                'enum' => ['ASC', 'DESC'],
            ],
            'mode' => [
                'type' => 'string',
                'default' => 'live',
                'enum' => ['live', 'test'],
            ],
            'status' => [
                'type' => 'array',
                'items' => [
                    'type' => 'string',
                    'enum' => [
                        'any',
                        'publish',
                        'give_subscription',
                        'pending',
                        'processing',
                        'refunded',
                        'revoked',
                        'failed',
                        'cancelled',
                        'abandoned',
                        'preapproval',
                    ],
                ],
                'default' => ['any'],
            ],
            'campaignId' => [
                'type' => 'integer',
                'default' => 0,
            ],
            'donorId' => [
                'type' => 'integer',
                'default' => 0,
            ],
            'subscriptionId' => [
                'type' => 'integer',
                'default' => 0,
            ],
            'includeSensitiveData' => [
                'type' => 'boolean',
                'default' => false,
            ],
            'anonymousDonations' => [
                'type' => 'string',
                'default' => 'exclude',
                'enum' => [
                    'exclude',
                    'include',
                    'redact',
                ],
            ],
            'force' => [
                'type' => 'boolean',
                'default' => false,
                'description' => 'Whether to permanently delete (force=true) or move to trash (force=false, default).',
            ],
        ];

        return $params;
    }

    /**
     * @since 4.13.0 updated embeddable links
     * @since 4.7.0 Add support for adding custom fields to the response
     * @since 4.6.0
     * @throws Exception
     */
    public function prepare_item_for_response($item, $request): WP_REST_Response
    {
        $donationId = $request->get_param('id') ?? $item['id'] ?? null;

        if ($donationId && $donation = Donation::find($donationId)) {
            $self_url = rest_url(sprintf('%s/%s/%d', $this->namespace, $this->rest_base, $donationId));

            $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'),
                    'includeSensitiveData' => $request->get_param('includeSensitiveData'),
                    'anonymousDonors' => $request->get_param('anonymousDonations'),
                ], $donor_url);

                $links[CURIE::relationUrl('donor')] = [
                    'href' => $donor_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' => $request->get_param('mode'),
                ], $campaign_url);

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

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

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

            // Add subscription link when subscriptionId is greater than 0
            if (isset($item['subscriptionId']) && $item['subscriptionId'] > 0) {
                $subscription_url = rest_url(sprintf('%s/%s/%d', $this->namespace, 'subscriptions', $item['subscriptionId']));
                $links[CURIE::relationUrl('subscription')] = [
                    'href' => $subscription_url,
                    'embeddable' => true,
                ];
            }
        } else {
            $links = [];
        }

        $responseItem = Item::formatDatesForResponse(
            $item,
            ['createdAt', 'updatedAt']
        );

        $response = new WP_REST_Response($responseItem);
        if (!empty($links)) {
            $response->add_links($links);
        }

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

        return $response;
    }

    /**
     * @since 4.14.0
     */
    public function get_item_permissions_check($request)
    {
        return $this->validationForGetMethods($request);
    }

    /**
     * @since 4.14.0
     */
    public function get_items_permissions_check($request)
    {
        return $this->validationForGetMethods($request);
    }

    /**
     * @since 4.14.0 update method name to validationForGetMethods, replace logic with UserPermissions facade and add canViewDonations check
     * @since 4.6.0
     */
    public function validationForGetMethods(WP_REST_Request $request)
    {
        $includeSensitiveData = $request->get_param('includeSensitiveData');
        $includeAnonymousDonations = $request->get_param('anonymousDonations');
        $canViewDonations = UserPermissions::donations()->canView();

        if ($includeSensitiveData && !$canViewDonations) {
            return new WP_Error(
                'rest_forbidden',
                __('You do not have permission to include sensitive data.', 'give'),
                ['status' => $this->authorizationStatusCode()]
            );
        }

        if ($includeAnonymousDonations !== null) {
            $anonymousMode = new DonationAnonymousMode($includeAnonymousDonations);

            if ($anonymousMode->isIncluded() && !$canViewDonations) {
                return new WP_Error(
                    'rest_forbidden',
                    __('You do not have permission to include anonymous donations.', 'give'),
                    ['status' => $this->authorizationStatusCode()]
                );
            }
        }

        return true;
    }

    /**
     * @since 4.6.0
     */
    public function update_item_permissions_check($request)
    {
        if ($this->canEditDonations()) {
            return true;
        }

        return new WP_Error(
            'rest_forbidden',
            __('You do not have permission to update donations.', 'give'),
            ['status' => $this->authorizationStatusCode()]
        );
    }

    /**
     * @since 4.6.0
     */
    public function create_item_permissions_check($request)
    {
        if ($this->canEditDonations()) {
            return true;
        }

        return new WP_Error(
            'rest_forbidden',
            __('You do not have permission to create donations.', 'give'),
            ['status' => $this->authorizationStatusCode()]
        );
    }

    /**
     * @since 4.8.0
     */
    public function delete_item_permissions_check($request)
    {
        if ($this->canDeleteDonations()) {
            return true;
        }

        return new WP_Error(
            'rest_forbidden',
            __('You do not have permission to delete donations.', 'give'),
            ['status' => $this->authorizationStatusCode()]
        );
    }

    /**
     * @since 4.6.0
     */
    public function delete_items_permissions_check($request)
    {
        if ($this->canDeleteDonations()) {
            return true;
        }

        return new WP_Error(
            'rest_forbidden',
            __('You do not have permission to delete donations.', 'give'),
            ['status' => $this->authorizationStatusCode()]
        );
    }

    /**
     * @since 4.6.0
     */
    public function refund_item_permissions_check($request)
    {
        if ($this->canRefundDonations()) {
            return true;
        }

        return new WP_Error(
            'rest_forbidden',
            __('You do not have permission to refund donations.', 'give'),
            ['status' => $this->authorizationStatusCode()]
        );
    }

    /**
     * Check if current user can edit donations.
     *
     * @since 4.14.0 replace logic with UserPermissions facade
     * @since 4.6.0
     */
    private function canEditDonations(): bool
    {
        return UserPermissions::donations()->canEdit();
    }

    /**
     * Check if current user can delete donations.
     *
     * @since 4.14.0 replace logic with UserPermissions facade
     * @since 4.6.0
     */
    private function canDeleteDonations(): bool
    {
        return UserPermissions::donations()->canDelete();
    }

    /**
     * Check if current user can refund donations.
     *
     * @since 4.14.0 replace logic with UserPermissions facade
     * @since 4.6.0
     */
    private function canRefundDonations(): bool
    {
        return UserPermissions::donations()->canEdit();
    }

    /**
     * @since 4.6.0
     */
    public function authorizationStatusCode(): int
    {
        return is_user_logged_in() ? 403 : 401;
    }

    /**
     * @since 4.13.0 Updated schema to match actual response, add schema description
     * @since 4.8.0 Change default status to complete
     * @since 4.7.0 Change title to givewp/donation and add custom fields schema
     * @since 4.6.1 Change type of billing address properties to accept null values
     * @since 4.6.0
     */
    public function get_item_schema(): array
    {
        $schema = [
            '$schema' => 'http://json-schema.org/draft-04/schema#',
            'title' => 'givewp/donation',
            'description' => esc_html__('Donation routes for CRUD operations', 'give'),
            'type' => 'object',
            'properties' => [
                'id' => [
                    'type' => 'integer',
                    'description' => esc_html__('Donation ID', 'give'),
                    'readonly' => true,
                ],
                'donorId' => [
                    'type' => 'integer',
                    'description' => esc_html__('Donor ID', 'give'),
                ],
                'firstName' => [
                    'type' => 'string',
                    'description' => esc_html__('Donor first name', 'give'),
                    'format' => 'text-field',
                ],
                'lastName' => [
                    'type' => ['string', 'null'],
                    'description' => esc_html__('Donor last name', 'give'),
                    'format' => 'text-field',
                ],
                'honorific' => [
                    'type' => ['string', 'null'],
                    'description' => esc_html__('Donor honorific/prefix', 'give'),
                    'enum' => $this->get_honorific_prefixes(),
                ],
                'email' => [
                    'type' => 'string',
                    'description' => esc_html__('Donor email', 'give'),
                    'format' => 'email',
                ],
                'phone' => [
                    'type' => ['string', 'null'],
                    'description' => esc_html__('Donor phone', 'give'),
                    'format' => 'text-field',
                ],
                'company' => [
                    'type' => ['string', 'null'],
                    'description' => esc_html__('Donor company', 'give'),
                    'format' => 'text-field',
                ],
                'amount' => SchemaTypes::money()->description(esc_html__('Donation amount', 'give'))->toArray(),
                'feeAmountRecovered' => SchemaTypes::money()->nullable()->description(esc_html__('Fee amount recovered', 'give'))->toArray(),
                'eventTicketsAmount' => SchemaTypes::money()->nullable()->readonly()->description(esc_html__('Event tickets amount', 'give'))->toArray(),
                'status' => [
                    'type' => 'string',
                    'description' => esc_html__('Donation status', 'give'),
                    'enum' => array_values(DonationStatus::toArray()),
                    'default' => DonationStatus::COMPLETE,
                ],
                'type' => [
                    'type' => 'string',
                    'description' => esc_html__('Donation type', 'give'),
                    'enum' => array_values(DonationType::toArray()),
                    'default' => DonationType::SINGLE,
                    'required' => true,
                ],
                'gatewayId' => [
                    'type' => 'string',
                    'description' => esc_html__('Payment gateway ID', 'give'),
                    'format' => 'text-field',
                ],
                'mode' => [
                    'type' => 'string',
                    'description' => esc_html__('Donation mode (live or test)', 'give'),
                    'enum' => array_values(DonationMode::toArray()),
                ],
                'anonymous' => [
                    'type' => 'boolean',
                    'description' => esc_html__('Whether the donation is anonymous', 'give'),
                    'default' => false,
                ],
                'campaignId' => [
                    'type' => 'integer',
                    'description' => esc_html__('Campaign ID', 'give'),
                ],
                'formId' => [
                    'type' => 'integer',
                    'description' => esc_html__('Form ID', 'give'),
                ],
                'formTitle' => [
                    'type' => 'string',
                    'description' => esc_html__('Form title', 'give'),
                    'format' => 'text-field',
                ],
                'subscriptionId' => [
                    'type' => ['integer', 'null'],
                    'description' => esc_html__('Subscription ID', 'give'),
                ],
                'levelId' => [
                    'type' => ['string', 'null'],
                    'description' => esc_html__('Level ID', 'give'),
                    'format' => 'text-field',
                ],
                'gatewayTransactionId' => [
                    'type' => ['string', 'null'],
                    'description' => esc_html__('Gateway transaction ID', 'give'),
                    'format' => 'text-field',
                ],
                'exchangeRate' => [
                    'type' => 'string',
                    'description' => esc_html__('Exchange rate', 'give'),
                    'format' => 'text-field',
                    'default' => '1',
                ],
                'comment' => [
                    'type' => ['string', 'null'],
                    'description' => esc_html__('Donation comment', 'give'),
                    'format' => 'text-field',
                ],
                'billingAddress' => [
                    'type' => ['object', 'null'],
                    'description' => esc_html__('Billing address', 'give'),
                    'properties' => [
                        'address1' => ['type' => ['string', 'null'], 'format' => 'text-field'],
                        'address2' => ['type' => ['string', 'null'], 'format' => 'text-field'],
                        'city' => ['type' => ['string', 'null'], 'format' => 'text-field'],
                        'state' => ['type' => ['string', 'null'], 'format' => 'text-field'],
                        'country' => ['type' => ['string', 'null'], 'format' => 'text-field'],
                        'zip' => ['type' => ['string', 'null'], 'format' => 'text-field'],
                    ],
                ],
                'donorIp' => [
                    'type' => ['string', 'null'],
                    'description' => esc_html__('Donor IP address (sensitive data)', 'give'),
                    'format' => 'text-field',
                ],
                'purchaseKey' => [
                    'type' => ['string', 'null'],
                    'description' => esc_html__('Purchase key (sensitive data)', 'give'),
                    'format' => 'text-field',
                ],
                'createdAt' => [
                    'type' => ['string', 'null'],
                    'description' => esc_html__('Created at Date and Time string', 'give'),
                    'format' => 'date-time',
                ],
                'updatedAt' => [
                    'type' => ['string', 'null'],
                    'description' => esc_html__('Created at Date and Time string', 'give'),
                    'format' => 'date-time',
                ],
                'updateRenewalDate' => [
                    'type' => 'boolean',
                    'description' => esc_html__('Whether to update the subscription renewal date with the createdAt date when creating subscription or renewal donations', 'give'),
                    'default' => false,
                ],
                'customFields' => [
                    'type' => 'array',
                    'readonly' => true,
                    'description' => esc_html__('Custom fields (sensitive data)', 'give'),
                    'items' => [
                        'type' => 'object',
                        'properties' => [
                            'label' => [
                                'type' => 'string',
                                'description' => esc_html__('Field label', 'give'),
                                'format' => 'text-field',
                            ],
                            'value' => [
                                'type' => 'string',
                                'description' => esc_html__('Field value', 'give'),
                                'format' => 'text-field',
                            ],
                        ],
                    ],
                ],
                'gateway' => [
                    'type' => 'object',
                    'readonly' => true,
                    'properties' => [
                        'id' => [
                            'type' => 'string',
                            'description' => esc_html__('Gateway ID', 'give'),
                        ],
                        'name' => [
                            'type' => 'string',
                            'description' => esc_html__('Gateway name', 'give'),
                        ],
                        'label' => [
                            'type' => 'string',
                            'description' => esc_html__('Payment method label', 'give'),
                        ],
                        'transactionUrl' => [
                            'type' => 'string',
                            'description' => esc_html__('Gateway transaction URL', 'give'),
                            'format' => 'uri',
                        ],
                    ],
                ],
                'eventTickets' => [
                    'type' => ['array', 'null'],
                    'readonly' => true,
                    'description' => esc_html__('Event tickets', 'give'),
                    'items' => [
                        'type' => 'object',
                        'properties' => [
                            'id' => [
                                'type' => 'integer',
                                'description' => esc_html__('Event ticket ID', 'give'),
                            ],
                            'eventId' => [
                                'type' => 'integer',
                                'description' => esc_html__('Event ID', 'give'),
                            ],
                            'ticketTypeId' => [
                                'type' => 'integer',
                                'description' => esc_html__('Ticket type ID', 'give'),
                            ],
                            'donationId' => [
                                'type' => 'integer',
                                'description' => esc_html__('Donation ID', 'give'),
                            ],
                            'amount' => SchemaTypes::money()->description(esc_html__('Event ticket amount', 'give'))->toArray(),
                            'createdAt' => [
                                'type' => 'string',
                                'description' => esc_html__('Created at Date and Time string', 'give'),
                                'format' => 'date-time',
                            ],
                            'updatedAt' => [
                                'type' => 'string',
                                'description' => esc_html__('Updated at Date and Time string', 'give'),
                                'format' => 'date-time',
                            ],
                            'event' => [
                                'type' => 'object',
                                'properties' => [
                                    'id' => [
                                        'type' => 'integer',
                                        'description' => esc_html__('Event ID', 'give'),
                                    ],
                                    'title' => [
                                        'type' => 'string',
                                        'description' => esc_html__('Event title', 'give'),
                                    ],
                                    'description' => [
                                        'type' => 'string',
                                        'description' => esc_html__('Event description', 'give'),
                                    ],
                                    'startDateTime' => [
                                        'type' => 'string',
                                        'description' => esc_html__('Event start date and time', 'give'),
                                        'format' => 'date-time',
                                    ],
                                    'endDateTime' => [
                                        'type' => 'string',
                                        'description' => esc_html__('Event end date and time', 'give'),
                                        'format' => 'date-time',
                                    ],
                                    'ticketCloseDateTime' => [
                                        'type' => 'string',
                                        'description' => esc_html__('Event ticket close date and time', 'give'),
                                        'format' => 'date-time',
                                    ],
                                    'createdAt' => [
                                        'type' => 'string',
                                        'description' => esc_html__('Event creation date and time', 'give'),
                                        'format' => 'date-time',
                                    ],
                                    'updatedAt' => [
                                        'type' => 'string',
                                        'description' => esc_html__('Event last update date and time', 'give'),
                                        'format' => 'date-time',
                                    ],
                                ],
                            ],
                            'ticketType' => [
                                'type' => 'object',
                                'properties' => [
                                    'id' => [
                                        'type' => 'integer',
                                        'description' => esc_html__('Ticket type ID', 'give'),
                                    ],
                                    'eventId' => [
                                        'type' => 'integer',
                                        'description' => esc_html__('Event ID', 'give'),
                                    ],
                                    'title' => [
                                        'type' => 'string',
                                        'description' => esc_html__('Ticket type title', 'give'),
                                    ],
                                    'description' => [
                                        'type' => 'string',
                                        'description' => esc_html__('Ticket type description', 'give'),
                                    ],
                                    'price' => SchemaTypes::money()->description(esc_html__('Ticket type price', 'give'))->toArray(),
                                    'capacity' => [
                                        'type' => 'integer',
                                        'description' => esc_html__('Ticket type capacity', 'give'),
                                    ],
                                    'createdAt' => [
                                        'type' => 'string',
                                        'description' => esc_html__('Ticket type creation date and time', 'give'),
                                        'format' => 'date-time',
                                    ],
                                'updatedAt' => [
                                    'type' => 'string',
                                    'description' => esc_html__('Ticket type last update date and time', 'give'),
                                    'format' => 'date-time',
                                ],
                                ],
                        ],
                    ],
                    ],
                ],
                'anyOf' => [
                    [
                        // 1) type = renewal -> require subscriptionId
                        [
                            'properties' => [
                                'type' => [
                                    'enum' => ['renewal'],
                                ],
                            ],
                            'required' => ['subscriptionId'],
                        ],

                        // 2) type = single -> require donorId, amount, gatewayId, mode, formId, firstName, email
                        [
                            'properties' => [
                                'type' => [
                                    'enum' => ['single'],
                                ],
                            ],
                            'required' => ['donorId', 'amount', 'gatewayId', 'mode', 'formId', 'firstName', 'email'],
                        ],

                        // 3) type = subscription -> require donorId, amount, gatewayId, mode, formId, firstName, email, subscriptionId
                        [
                            'properties' => [
                                'type' => [
                                    'enum' => ['subscription'],
                                ],
                            ],
                            'required' => ['donorId', 'amount', 'gatewayId', 'mode', 'formId', 'firstName', 'email', 'subscriptionId'],
                        ],
                    ],
                ],
            ],
        ];

        return $this->add_additional_fields_schema($schema);
    }

    /**
     * Gets all available honorific prefixes.
     *
     * Fetches the user-configured honorific prefixes from settings and merges them
     * with a hardcoded 'anonymous' prefix. The 'anonymous' prefix is required
     * when requests with anonymousDonations=redact are present.
     *
     * @return array<string> An array of honorific prefixes.
     */
    private function get_honorific_prefixes(): array {
        $prefixes = (array) give_get_option( 'title_prefixes', array_values( give_get_default_title_prefixes() ) );

        return array_merge( $prefixes, ['anonymous', null] );
    }
}