# easy-invoice/2.4.1/includes/Rest/RestController.php

Easy Invoice – Invoice Generator, PDF Quotes &amp; Payments, version 2.4.1. 668 lines.

- Page: https://pluginprobe.com/plugins/easy-invoice/2.4.1/code/includes/Rest/RestController.php
- Raw: https://pluginprobe.com/plugins/easy-invoice/2.4.1/raw/includes/Rest/RestController.php
- Modified: 2026-09-15T12:31:20+00:00

Line numbers below start at 1. Link to a line or a range by appending a fragment to the
page URL, for example `https://pluginprobe.com/plugins/easy-invoice/2.4.1/code/includes/Rest/RestController.php#L10-L20`.

```php
<?php
/**
 * REST API for Easy Invoice.
 *
 * @package Easy_Invoice
 * @subpackage Rest
 */

namespace EasyInvoice\Rest;

if ( ! defined( 'ABSPATH' ) ) {
    exit;
}

/**
 * The `easy-invoice/v1` namespace.
 *
 * Why this exists
 * ---------------
 * The plugin had 83 admin-ajax handlers and no REST surface at all — the
 * documentation told integrators to POST at admin-ajax.php with a nonce, which
 * only works from inside a logged-in browser session. That rules out mobile
 * apps, headless front-ends, accounting integrations, and anything that
 * authenticates with an application password.
 *
 * It also blocks e-invoicing: a Peppol access point has to be able to fetch an
 * invoice and post back a delivery status, and it is a server, not a browser.
 *
 * Security posture
 * ----------------
 * A REST namespace is new attack surface, so every route here is authenticated
 * and capability-checked, using the same `ei_*` capabilities the admin screens
 * use — an EI Viewer gets read access and nothing more, and the Team Roles addon
 * keeps working unchanged. There are no public routes.
 *
 * Responses are assembled field by field rather than dumping post meta. That is
 * deliberate: invoices carry a per-document access token that acts as a bearer
 * credential for the public payment page, and a meta dump would hand it to
 * anyone who could read an invoice.
 */
class RestController {

    /** API namespace. */
    const NAMESPACE = 'easy-invoice/v1';

    /**
     * Hook route registration.
     *
     * @return void
     */
    public static function init(): void {
        add_action( 'rest_api_init', [ __CLASS__, 'registerRoutes' ] );
    }

    /**
     * Register every route in the namespace.
     *
     * @return void
     */
    public static function registerRoutes(): void {
        $id_arg = [
            'id' => [
                'description'       => __( 'Document ID.', 'easy-invoice' ),
                'type'              => 'integer',
                'required'          => true,
                'sanitize_callback' => 'absint',
                'validate_callback' => static function ( $value ) {
                    return absint( $value ) > 0;
                },
            ],
        ];

        register_rest_route( self::NAMESPACE, '/invoices', [
            [
                'methods'             => \WP_REST_Server::READABLE,
                'callback'            => [ __CLASS__, 'listInvoices' ],
                'permission_callback' => [ __CLASS__, 'canViewInvoices' ],
                'args'                => self::collectionArgs(),
            ],
            [
                'methods'             => \WP_REST_Server::CREATABLE,
                'callback'            => [ __CLASS__, 'createInvoice' ],
                'permission_callback' => [ __CLASS__, 'canCreateInvoice' ],
            ],
        ] );

        register_rest_route( self::NAMESPACE, '/invoices/(?P<id>\d+)', [
            [
                'methods'             => \WP_REST_Server::READABLE,
                'callback'            => [ __CLASS__, 'getInvoice' ],
                'permission_callback' => [ __CLASS__, 'canViewInvoices' ],
                'args'                => $id_arg,
            ],
            [
                'methods'             => \WP_REST_Server::DELETABLE,
                'callback'            => [ __CLASS__, 'deleteInvoice' ],
                'permission_callback' => [ __CLASS__, 'canDeleteInvoice' ],
                'args'                => $id_arg,
            ],
        ] );

        // The PDF endpoint only became possible once rendering moved to the
        // server; before that there was no document outside a browser.
        register_rest_route( self::NAMESPACE, '/invoices/(?P<id>\d+)/pdf', [
            'methods'             => \WP_REST_Server::READABLE,
            'callback'            => [ __CLASS__, 'getInvoicePdf' ],
            'permission_callback' => [ __CLASS__, 'canViewInvoices' ],
            'args'                => $id_arg,
        ] );

        register_rest_route( self::NAMESPACE, '/quotes', [
            'methods'             => \WP_REST_Server::READABLE,
            'callback'            => [ __CLASS__, 'listQuotes' ],
            'permission_callback' => [ __CLASS__, 'canViewQuotes' ],
            'args'                => self::collectionArgs(),
        ] );

        register_rest_route( self::NAMESPACE, '/quotes/(?P<id>\d+)', [
            'methods'             => \WP_REST_Server::READABLE,
            'callback'            => [ __CLASS__, 'getQuote' ],
            'permission_callback' => [ __CLASS__, 'canViewQuotes' ],
            'args'                => $id_arg,
        ] );

        register_rest_route( self::NAMESPACE, '/clients', [
            'methods'             => \WP_REST_Server::READABLE,
            'callback'            => [ __CLASS__, 'listClients' ],
            'permission_callback' => [ __CLASS__, 'canViewClients' ],
            'args'                => self::collectionArgs(),
        ] );
    }

    // ── Permissions ──────────────────────────────────────────────────────

    /**
     * Everything here requires a signed-in user; there are no public routes.
     *
     * @param string $capability Capability to require.
     * @return bool|\WP_Error
     */
    private static function require( string $capability ) {
        if ( ! is_user_logged_in() ) {
            return new \WP_Error(
                'easy_invoice_rest_unauthenticated',
                __( 'You must be signed in to use this endpoint.', 'easy-invoice' ),
                [ 'status' => 401 ]
            );
        }

        $allowed = function_exists( 'easy_invoice_user_can' )
            ? easy_invoice_user_can( $capability )
            : current_user_can( 'manage_options' );

        if ( ! $allowed ) {
            return new \WP_Error(
                'easy_invoice_rest_forbidden',
                __( 'You do not have permission to do that.', 'easy-invoice' ),
                [ 'status' => 403 ]
            );
        }

        return true;
    }

    /** @return bool|\WP_Error */
    public static function canViewInvoices() {
        return self::require( 'ei_view_invoices' );
    }

    /** @return bool|\WP_Error */
    public static function canCreateInvoice() {
        return self::require( 'ei_create_invoice' );
    }

    /** @return bool|\WP_Error */
    public static function canDeleteInvoice() {
        return self::require( 'ei_delete_invoice' );
    }

    /** @return bool|\WP_Error */
    public static function canViewQuotes() {
        return self::require( 'ei_view_quotes' );
    }

    /** @return bool|\WP_Error */
    public static function canViewClients() {
        return self::require( 'ei_view_clients' );
    }

    // ── Arguments ────────────────────────────────────────────────────────

    /**
     * Shared pagination arguments.
     *
     * @return array
     */
    private static function collectionArgs(): array {
        return [
            'page'     => [
                'description'       => __( 'Page of results to return.', 'easy-invoice' ),
                'type'              => 'integer',
                'default'           => 1,
                'sanitize_callback' => 'absint',
            ],
            'per_page' => [
                'description'       => __( 'Results per page, to a maximum of 100.', 'easy-invoice' ),
                'type'              => 'integer',
                'default'           => 20,
                'sanitize_callback' => 'absint',
            ],
            'search'   => [
                'description'       => __( 'Limit results to those matching a string.', 'easy-invoice' ),
                'type'              => 'string',
                'default'           => '',
                'sanitize_callback' => 'sanitize_text_field',
            ],
        ];
    }

    // ── Invoices ─────────────────────────────────────────────────────────

    /**
     * List invoices.
     *
     * @param \WP_REST_Request $request Request.
     * @return \WP_REST_Response
     */
    public static function listInvoices( $request ) {
        return self::listDocuments(
            $request,
            \EasyInvoice\Constants\PostTypes::EASY_INVOICE_POST_TYPE,
            [ __CLASS__, 'shapeInvoice' ]
        );
    }

    /**
     * Fetch one invoice.
     *
     * @param \WP_REST_Request $request Request.
     * @return \WP_REST_Response|\WP_Error
     */
    public static function getInvoice( $request ) {
        $post = self::documentOr404( (int) $request['id'], \EasyInvoice\Constants\PostTypes::EASY_INVOICE_POST_TYPE );
        if ( is_wp_error( $post ) ) {
            return $post;
        }

        return rest_ensure_response( self::shapeInvoice( new \EasyInvoice\Models\Invoice( $post ) ) );
    }

    /**
     * Create an invoice.
     *
     * Accepts the same shape the read endpoints return, so a client can round-trip
     * a document without translating between two vocabularies.
     *
     * @param \WP_REST_Request $request Request.
     * @return \WP_REST_Response|\WP_Error
     */
    public static function createInvoice( $request ) {
        $title = sanitize_text_field( (string) $request->get_param( 'title' ) );
        $items = $request->get_param( 'items' );

        if ( ! is_array( $items ) || empty( $items ) ) {
            return new \WP_Error(
                'easy_invoice_rest_no_items',
                __( 'An invoice needs at least one line item.', 'easy-invoice' ),
                [ 'status' => 400 ]
            );
        }

        $status = sanitize_key( (string) $request->get_param( 'status' ) );
        if ( ! in_array( $status, [ 'draft', 'available' ], true ) ) {
            $status = 'draft';
        }

        // Go through the repository so an API-created invoice is a first-class
        // one: numbered from the sequence, given a status and access token,
        // filled from the client record, and announced on the same hooks the
        // admin screens fire (webhooks, recurring, reminders all listen there).
        $data = [
            'title'  => $title !== '' ? $title : __( 'Invoice', 'easy-invoice' ),
            'status' => $status,
            'items'  => self::sanitiseItems( $items ),
        ];

        $client_id = absint( $request->get_param( 'client_id' ) );
        if ( $client_id > 0 ) {
            if ( ! get_userdata( $client_id ) ) {
                return new \WP_Error(
                    'easy_invoice_rest_no_client',
                    __( 'No client with that ID.', 'easy-invoice' ),
                    [ 'status' => 400 ]
                );
            }
            $data['client_id'] = $client_id;
        }

        $map = [
            'number'         => 'number',
            'issue_date'     => 'issue_date',
            'due_date'       => 'due_date',
            'notes'          => 'notes',
            'terms'          => 'terms_and_conditions',
            'customer_name'  => 'customer_name',
            'customer_email' => 'customer_email',
            'currency'       => 'currency_code',
        ];
        foreach ( $map as $param => $field ) {
            $value = $request->get_param( $param );
            if ( null !== $value && '' !== $value ) {
                $data[ $field ] = ( 'customer_email' === $param )
                    ? sanitize_email( (string) $value )
                    : sanitize_text_field( (string) $value );
            }
        }

        $invoice = \EasyInvoice\Providers\InvoiceServiceProvider::getInvoiceRepository()->create( $data );
        if ( ! $invoice || ! $invoice->getId() ) {
            return new \WP_Error(
                'easy_invoice_rest_create_failed',
                __( 'The invoice could not be saved.', 'easy-invoice' ),
                [ 'status' => 500 ]
            );
        }
        $post_id = (int) $invoice->getId();

        foreach ( [ 'customer_vat_number', 'customer_country' ] as $field ) {
            $value = $request->get_param( $field );
            if ( null !== $value ) {
                update_post_meta( $post_id, '_easy_invoice_' . $field, sanitize_text_field( (string) $value ) );
            }
        }

        $response = rest_ensure_response( self::shapeInvoice( new \EasyInvoice\Models\Invoice( get_post( $post_id ) ) ) );
        $response->set_status( 201 );

        return $response;
    }

    /**
     * Delete an invoice.
     *
     * @param \WP_REST_Request $request Request.
     * @return \WP_REST_Response|\WP_Error
     */
    public static function deleteInvoice( $request ) {
        $post = self::documentOr404( (int) $request['id'], \EasyInvoice\Constants\PostTypes::EASY_INVOICE_POST_TYPE );
        if ( is_wp_error( $post ) ) {
            return $post;
        }

        // Trash rather than erase, matching what the admin screens do — an invoice
        // is a financial record and a DELETE over HTTP should not be unrecoverable.
        $result = wp_trash_post( $post->ID );

        return rest_ensure_response( [
            'deleted' => (bool) $result,
            'id'      => (int) $post->ID,
        ] );
    }

    /**
     * Return an invoice as a PDF.
     *
     * @param \WP_REST_Request $request Request.
     * @return \WP_REST_Response|\WP_Error
     */
    public static function getInvoicePdf( $request ) {
        $post = self::documentOr404( (int) $request['id'], \EasyInvoice\Constants\PostTypes::EASY_INVOICE_POST_TYPE );
        if ( is_wp_error( $post ) ) {
            return $post;
        }

        $pdf = \EasyInvoice\Services\PdfRenderer::renderInvoice( new \EasyInvoice\Models\Invoice( $post ) );
        if ( is_wp_error( $pdf ) ) {
            $pdf->add_data( [ 'status' => 500 ] );
            return $pdf;
        }

        // Emit the file directly. Returning base64 in JSON would double the
        // payload and force every client to decode it.
        $number = (string) get_post_meta( $post->ID, '_easy_invoice_number', true );
        $name   = sanitize_file_name( ( $number !== '' ? $number : 'invoice-' . $post->ID ) . '.pdf' );

        header( 'Content-Type: application/pdf' );
        header( 'Content-Disposition: attachment; filename="' . $name . '"' );
        header( 'Content-Length: ' . strlen( $pdf ) );
        echo $pdf; // phpcs:ignore WordPress.Security.EscapeOutput -- binary PDF.
        exit;
    }

    // ── Quotes and clients ───────────────────────────────────────────────

    /**
     * List quotes.
     *
     * @param \WP_REST_Request $request Request.
     * @return \WP_REST_Response
     */
    public static function listQuotes( $request ) {
        return self::listDocuments(
            $request,
            \EasyInvoice\Constants\PostTypes::EASY_INVOICE_QUOTE_POST_TYPE,
            [ __CLASS__, 'shapeQuote' ]
        );
    }

    /**
     * Fetch one quote.
     *
     * @param \WP_REST_Request $request Request.
     * @return \WP_REST_Response|\WP_Error
     */
    public static function getQuote( $request ) {
        $post = self::documentOr404( (int) $request['id'], \EasyInvoice\Constants\PostTypes::EASY_INVOICE_QUOTE_POST_TYPE );
        if ( is_wp_error( $post ) ) {
            return $post;
        }

        return rest_ensure_response( self::shapeQuote( new \EasyInvoice\Models\Quote( $post ) ) );
    }

    /**
     * List clients.
     *
     * @param \WP_REST_Request $request Request.
     * @return \WP_REST_Response
     */
    public static function listClients( $request ) {
        $per_page = min( 100, max( 1, (int) $request->get_param( 'per_page' ) ) );
        $page     = max( 1, (int) $request->get_param( 'page' ) );

        $query = new \WP_User_Query( [
            'number'       => $per_page,
            'paged'        => $page,
            'role__not_in' => [ 'Administrator' ],
            'search'       => $request->get_param( 'search' ) ? '*' . $request->get_param( 'search' ) . '*' : '',
            'orderby'      => 'ID',
            'order'        => 'DESC',
        ] );

        $clients = [];
        foreach ( $query->get_results() as $user ) {
            $clients[] = [
                'id'    => (int) $user->ID,
                'name'  => $user->display_name,
                'email' => $user->user_email,
            ];
        }

        $response = rest_ensure_response( $clients );
        $response->header( 'X-WP-Total', (int) $query->get_total() );

        return $response;
    }

    // ── Shared plumbing ──────────────────────────────────────────────────

    /**
     * List documents of a post type, paginated.
     *
     * @param \WP_REST_Request $request   Request.
     * @param string           $post_type Post type.
     * @param callable         $shape     Serialiser.
     * @return \WP_REST_Response
     */
    private static function listDocuments( $request, string $post_type, callable $shape ) {
        $per_page = min( 100, max( 1, (int) $request->get_param( 'per_page' ) ) );
        $page     = max( 1, (int) $request->get_param( 'page' ) );
        $search   = (string) $request->get_param( 'search' );

        $query = new \WP_Query( [
            'post_type'      => $post_type,
            'post_status'    => [ 'publish', 'draft', 'pending', 'private' ],
            'posts_per_page' => $per_page,
            'paged'          => $page,
            's'              => $search,
            'orderby'        => 'ID',
            'order'          => 'DESC',
        ] );

        $items = [];
        foreach ( $query->posts as $post ) {
            $model = ( \EasyInvoice\Constants\PostTypes::EASY_INVOICE_QUOTE_POST_TYPE === $post_type )
                ? new \EasyInvoice\Models\Quote( $post )
                : new \EasyInvoice\Models\Invoice( $post );
            $items[] = call_user_func( $shape, $model );
        }

        $response = rest_ensure_response( $items );
        $response->header( 'X-WP-Total', (int) $query->found_posts );
        $response->header( 'X-WP-TotalPages', (int) $query->max_num_pages );

        return $response;
    }

    /**
     * Load a document of the expected type, or a 404.
     *
     * The 404 is deliberate for a wrong post type too: confirming that an id
     * exists but is something else is information the caller has no need for.
     *
     * @param int    $id        Post ID.
     * @param string $post_type Expected post type.
     * @return \WP_Post|\WP_Error
     */
    private static function documentOr404( int $id, string $post_type ) {
        $post = $id > 0 ? get_post( $id ) : null;

        if ( ! $post || $post->post_type !== $post_type ) {
            return new \WP_Error(
                'easy_invoice_rest_not_found',
                __( 'No document with that ID.', 'easy-invoice' ),
                [ 'status' => 404 ]
            );
        }

        return $post;
    }

    /**
     * Serialise an invoice.
     *
     * Assembled field by field on purpose — see the class docblock. The
     * per-document access token is a bearer credential for the public payment
     * page and must never appear here.
     *
     * @param \EasyInvoice\Models\Invoice $invoice Invoice.
     * @return array
     */
    public static function shapeInvoice( $invoice ): array {
        $id   = (int) $invoice->getId();
        $data = [
            'id'         => $id,
            'number'     => (string) $invoice->getNumber(),
            'title'      => (string) $invoice->getTitle(),
            'status'     => (string) get_post_meta( $id, '_easy_invoice_status', true ),
            'viewed'     => \EasyInvoice\Services\DocumentViews::summary( $id ),
            'issue_date' => (string) $invoice->getIssueDate(),
            'due_date'   => (string) $invoice->getDueDate(),
            'customer'   => [
                'name'    => (string) $invoice->getCustomerName(),
                'email'   => (string) $invoice->getCustomerEmail(),
                'country' => (string) get_post_meta( $id, '_easy_invoice_customer_country', true ),
                'vat'     => (string) get_post_meta( $id, '_easy_invoice_customer_vat_number', true ),
            ],
            'totals'     => [
                'subtotal' => (float) $invoice->getSubtotal(),
                'discount' => (float) $invoice->getDiscountAmount(),
                'tax'      => (float) $invoice->getTaxAmount(),
                'total'    => (float) $invoice->getTotal(),
                'paid'     => \EasyInvoice\Services\InvoiceBalance::paid( $id ),
                'credited' => \EasyInvoice\Services\InvoiceBalance::credited( $id ),
                'due'      => \EasyInvoice\Services\InvoiceBalance::due( $invoice ),
            ],
            'items'      => self::shapeItems( $invoice ),
            'links'      => [
                'pdf' => rest_url( self::NAMESPACE . '/invoices/' . $id . '/pdf' ),
            ],
        ];

        if ( class_exists( '\EasyInvoice\Services\TaxTreatment' ) ) {
            $treatment            = \EasyInvoice\Services\TaxTreatment::forDocument( $invoice );
            $data['tax_treatment'] = [
                'category'  => $treatment['category'],
                'statement' => \EasyInvoice\Services\TaxTreatment::statementFor( $invoice ),
            ];
        }

        /**
         * Filter the invoice representation returned by the REST API.
         *
         * @param array  $data    Serialised invoice.
         * @param object $invoice Invoice model.
         */
        return (array) apply_filters( 'easy_invoice_rest_invoice', $data, $invoice );
    }

    /**
     * Serialise a quote.
     *
     * @param \EasyInvoice\Models\Quote $quote Quote.
     * @return array
     */
    public static function shapeQuote( $quote ): array {
        $id   = (int) $quote->getId();
        $data = [
            'id'          => $id,
            'number'      => (string) get_post_meta( $id, '_easy_invoice_quote_number', true ),
            'title'       => is_callable( [ $quote, 'getTitle' ] ) ? (string) $quote->getTitle() : '',
            'status'      => is_callable( [ $quote, 'getStatus' ] ) ? (string) $quote->getStatus() : '',
            'issue_date'  => (string) get_post_meta( $id, '_easy_invoice_quote_issue_date', true ),
            'expiry_date' => (string) get_post_meta( $id, '_easy_invoice_quote_expiry_date', true ),
            'totals'      => [
                'total' => is_callable( [ $quote, 'getTotal' ] ) ? (float) $quote->getTotal() : 0.0,
            ],
            'items'       => self::shapeItems( $quote ),
        ];

        /**
         * Filter the quote representation returned by the REST API.
         *
         * @param array  $data  Serialised quote.
         * @param object $quote Quote model.
         */
        return (array) apply_filters( 'easy_invoice_rest_quote', $data, $quote );
    }

    /**
     * Serialise a document's line items.
     *
     * @param object $document Invoice or Quote model.
     * @return array
     */
    private static function shapeItems( $document ): array {
        if ( ! is_callable( [ $document, 'getItems' ] ) ) {
            return [];
        }

        $out = [];
        foreach ( (array) $document->getItems() as $item ) {
            $out[] = [
                'name'        => is_callable( [ $item, 'getName' ] ) ? (string) $item->getName() : '',
                'description' => is_callable( [ $item, 'getDescription' ] ) ? (string) $item->getDescription() : '',
                'quantity'    => is_callable( [ $item, 'getQuantity' ] ) ? (float) $item->getQuantity() : 0.0,
                'price'       => is_callable( [ $item, 'getPrice' ] ) ? (float) $item->getPrice() : 0.0,
                'amount'      => is_callable( [ $item, 'getAmount' ] ) ? (float) $item->getAmount() : 0.0,
                'taxable'     => is_callable( [ $item, 'isTaxable' ] ) ? (bool) $item->isTaxable() : true,
            ];
        }

        return $out;
    }

    /**
     * Clean line items arriving from a client.
     *
     * @param array $items Raw items.
     * @return array
     */
    private static function sanitiseItems( array $items ): array {
        $clean = [];

        foreach ( $items as $item ) {
            if ( ! is_array( $item ) ) {
                continue;
            }

            $quantity = isset( $item['quantity'] ) ? (float) $item['quantity'] : 0.0;
            $price    = isset( $item['price'] ) ? (float) $item['price'] : 0.0;

            $clean[] = [
                'title'              => sanitize_text_field( (string) ( $item['name'] ?? $item['title'] ?? '' ) ),
                'description'        => sanitize_textarea_field( (string) ( $item['description'] ?? '' ) ),
                'quantity'           => $quantity,
                'price'              => $price,
                'adjust_percentage'  => isset( $item['adjust_percentage'] ) ? (float) $item['adjust_percentage'] : 0.0,
                'total'              => $quantity * $price,
                'taxable'            => isset( $item['taxable'] ) ? (bool) $item['taxable'] : true,
                'id'                 => 0,
            ];
        }

        return $clean;
    }
}

```
