[ '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\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\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\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; } }