PluginProbe
Easy Invoice – Invoice Generator, PDF Quotes & Payments / 2.4.0
Easy Invoice – Invoice Generator, PDF Quotes & Payments v2.4.0
2.4.0 2.4.1 2.3.8 2.3.7 2.3.6 2.3.5 2.3.4 2.3.3 2.3.2 2.3.1 2.2.0 2.1.21 2.1.20 2.1.19 2.1.18 2.1.0 2.1.1 2.1.10 2.1.11 2.1.12 2.1.13 2.1.14 2.1.15 2.1.16 2.1.2 All 57 releases
easy-invoice / includes / Rest / RestController.php

RestController.php in Easy Invoice – Invoice Generator, PDF Quotes & Payments 2.4.0, at includes/Rest/RestController.php

668 lines 25.2 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2 /**
3 * REST API for Easy Invoice.
4 *
5 * @package Easy_Invoice
6 * @subpackage Rest
7 */
8
9 namespace EasyInvoice\Rest;
10
11 if ( ! defined( 'ABSPATH' ) ) {
12 exit;
13 }
14
15 /**
16 * The `easy-invoice/v1` namespace.
17 *
18 * Why this exists
19 * ---------------
20 * The plugin had 83 admin-ajax handlers and no REST surface at all — the
21 * documentation told integrators to POST at admin-ajax.php with a nonce, which
22 * only works from inside a logged-in browser session. That rules out mobile
23 * apps, headless front-ends, accounting integrations, and anything that
24 * authenticates with an application password.
25 *
26 * It also blocks e-invoicing: a Peppol access point has to be able to fetch an
27 * invoice and post back a delivery status, and it is a server, not a browser.
28 *
29 * Security posture
30 * ----------------
31 * A REST namespace is new attack surface, so every route here is authenticated
32 * and capability-checked, using the same `ei_*` capabilities the admin screens
33 * use — an EI Viewer gets read access and nothing more, and the Team Roles addon
34 * keeps working unchanged. There are no public routes.
35 *
36 * Responses are assembled field by field rather than dumping post meta. That is
37 * deliberate: invoices carry a per-document access token that acts as a bearer
38 * credential for the public payment page, and a meta dump would hand it to
39 * anyone who could read an invoice.
40 */
41 class RestController {
42
43 /** API namespace. */
44 const NAMESPACE = 'easy-invoice/v1';
45
46 /**
47 * Hook route registration.
48 *
49 * @return void
50 */
51 public static function init(): void {
52 add_action( 'rest_api_init', [ __CLASS__, 'registerRoutes' ] );
53 }
54
55 /**
56 * Register every route in the namespace.
57 *
58 * @return void
59 */
60 public static function registerRoutes(): void {
61 $id_arg = [
62 'id' => [
63 'description' => __( 'Document ID.', 'easy-invoice' ),
64 'type' => 'integer',
65 'required' => true,
66 'sanitize_callback' => 'absint',
67 'validate_callback' => static function ( $value ) {
68 return absint( $value ) > 0;
69 },
70 ],
71 ];
72
73 register_rest_route( self::NAMESPACE, '/invoices', [
74 [
75 'methods' => \WP_REST_Server::READABLE,
76 'callback' => [ __CLASS__, 'listInvoices' ],
77 'permission_callback' => [ __CLASS__, 'canViewInvoices' ],
78 'args' => self::collectionArgs(),
79 ],
80 [
81 'methods' => \WP_REST_Server::CREATABLE,
82 'callback' => [ __CLASS__, 'createInvoice' ],
83 'permission_callback' => [ __CLASS__, 'canCreateInvoice' ],
84 ],
85 ] );
86
87 register_rest_route( self::NAMESPACE, '/invoices/(?P<id>\d+)', [
88 [
89 'methods' => \WP_REST_Server::READABLE,
90 'callback' => [ __CLASS__, 'getInvoice' ],
91 'permission_callback' => [ __CLASS__, 'canViewInvoices' ],
92 'args' => $id_arg,
93 ],
94 [
95 'methods' => \WP_REST_Server::DELETABLE,
96 'callback' => [ __CLASS__, 'deleteInvoice' ],
97 'permission_callback' => [ __CLASS__, 'canDeleteInvoice' ],
98 'args' => $id_arg,
99 ],
100 ] );
101
102 // The PDF endpoint only became possible once rendering moved to the
103 // server; before that there was no document outside a browser.
104 register_rest_route( self::NAMESPACE, '/invoices/(?P<id>\d+)/pdf', [
105 'methods' => \WP_REST_Server::READABLE,
106 'callback' => [ __CLASS__, 'getInvoicePdf' ],
107 'permission_callback' => [ __CLASS__, 'canViewInvoices' ],
108 'args' => $id_arg,
109 ] );
110
111 register_rest_route( self::NAMESPACE, '/quotes', [
112 'methods' => \WP_REST_Server::READABLE,
113 'callback' => [ __CLASS__, 'listQuotes' ],
114 'permission_callback' => [ __CLASS__, 'canViewQuotes' ],
115 'args' => self::collectionArgs(),
116 ] );
117
118 register_rest_route( self::NAMESPACE, '/quotes/(?P<id>\d+)', [
119 'methods' => \WP_REST_Server::READABLE,
120 'callback' => [ __CLASS__, 'getQuote' ],
121 'permission_callback' => [ __CLASS__, 'canViewQuotes' ],
122 'args' => $id_arg,
123 ] );
124
125 register_rest_route( self::NAMESPACE, '/clients', [
126 'methods' => \WP_REST_Server::READABLE,
127 'callback' => [ __CLASS__, 'listClients' ],
128 'permission_callback' => [ __CLASS__, 'canViewClients' ],
129 'args' => self::collectionArgs(),
130 ] );
131 }
132
133 // ── Permissions ──────────────────────────────────────────────────────
134
135 /**
136 * Everything here requires a signed-in user; there are no public routes.
137 *
138 * @param string $capability Capability to require.
139 * @return bool|\WP_Error
140 */
141 private static function require( string $capability ) {
142 if ( ! is_user_logged_in() ) {
143 return new \WP_Error(
144 'easy_invoice_rest_unauthenticated',
145 __( 'You must be signed in to use this endpoint.', 'easy-invoice' ),
146 [ 'status' => 401 ]
147 );
148 }
149
150 $allowed = function_exists( 'easy_invoice_user_can' )
151 ? easy_invoice_user_can( $capability )
152 : current_user_can( 'manage_options' );
153
154 if ( ! $allowed ) {
155 return new \WP_Error(
156 'easy_invoice_rest_forbidden',
157 __( 'You do not have permission to do that.', 'easy-invoice' ),
158 [ 'status' => 403 ]
159 );
160 }
161
162 return true;
163 }
164
165 /** @return bool|\WP_Error */
166 public static function canViewInvoices() {
167 return self::require( 'ei_view_invoices' );
168 }
169
170 /** @return bool|\WP_Error */
171 public static function canCreateInvoice() {
172 return self::require( 'ei_create_invoice' );
173 }
174
175 /** @return bool|\WP_Error */
176 public static function canDeleteInvoice() {
177 return self::require( 'ei_delete_invoice' );
178 }
179
180 /** @return bool|\WP_Error */
181 public static function canViewQuotes() {
182 return self::require( 'ei_view_quotes' );
183 }
184
185 /** @return bool|\WP_Error */
186 public static function canViewClients() {
187 return self::require( 'ei_view_clients' );
188 }
189
190 // ── Arguments ────────────────────────────────────────────────────────
191
192 /**
193 * Shared pagination arguments.
194 *
195 * @return array
196 */
197 private static function collectionArgs(): array {
198 return [
199 'page' => [
200 'description' => __( 'Page of results to return.', 'easy-invoice' ),
201 'type' => 'integer',
202 'default' => 1,
203 'sanitize_callback' => 'absint',
204 ],
205 'per_page' => [
206 'description' => __( 'Results per page, to a maximum of 100.', 'easy-invoice' ),
207 'type' => 'integer',
208 'default' => 20,
209 'sanitize_callback' => 'absint',
210 ],
211 'search' => [
212 'description' => __( 'Limit results to those matching a string.', 'easy-invoice' ),
213 'type' => 'string',
214 'default' => '',
215 'sanitize_callback' => 'sanitize_text_field',
216 ],
217 ];
218 }
219
220 // ── Invoices ─────────────────────────────────────────────────────────
221
222 /**
223 * List invoices.
224 *
225 * @param \WP_REST_Request $request Request.
226 * @return \WP_REST_Response
227 */
228 public static function listInvoices( $request ) {
229 return self::listDocuments(
230 $request,
231 \EasyInvoice\Constants\PostTypes::EASY_INVOICE_POST_TYPE,
232 [ __CLASS__, 'shapeInvoice' ]
233 );
234 }
235
236 /**
237 * Fetch one invoice.
238 *
239 * @param \WP_REST_Request $request Request.
240 * @return \WP_REST_Response|\WP_Error
241 */
242 public static function getInvoice( $request ) {
243 $post = self::documentOr404( (int) $request['id'], \EasyInvoice\Constants\PostTypes::EASY_INVOICE_POST_TYPE );
244 if ( is_wp_error( $post ) ) {
245 return $post;
246 }
247
248 return rest_ensure_response( self::shapeInvoice( new \EasyInvoice\Models\Invoice( $post ) ) );
249 }
250
251 /**
252 * Create an invoice.
253 *
254 * Accepts the same shape the read endpoints return, so a client can round-trip
255 * a document without translating between two vocabularies.
256 *
257 * @param \WP_REST_Request $request Request.
258 * @return \WP_REST_Response|\WP_Error
259 */
260 public static function createInvoice( $request ) {
261 $title = sanitize_text_field( (string) $request->get_param( 'title' ) );
262 $items = $request->get_param( 'items' );
263
264 if ( ! is_array( $items ) || empty( $items ) ) {
265 return new \WP_Error(
266 'easy_invoice_rest_no_items',
267 __( 'An invoice needs at least one line item.', 'easy-invoice' ),
268 [ 'status' => 400 ]
269 );
270 }
271
272 $status = sanitize_key( (string) $request->get_param( 'status' ) );
273 if ( ! in_array( $status, [ 'draft', 'available' ], true ) ) {
274 $status = 'draft';
275 }
276
277 // Go through the repository so an API-created invoice is a first-class
278 // one: numbered from the sequence, given a status and access token,
279 // filled from the client record, and announced on the same hooks the
280 // admin screens fire (webhooks, recurring, reminders all listen there).
281 $data = [
282 'title' => $title !== '' ? $title : __( 'Invoice', 'easy-invoice' ),
283 'status' => $status,
284 'items' => self::sanitiseItems( $items ),
285 ];
286
287 $client_id = absint( $request->get_param( 'client_id' ) );
288 if ( $client_id > 0 ) {
289 if ( ! get_userdata( $client_id ) ) {
290 return new \WP_Error(
291 'easy_invoice_rest_no_client',
292 __( 'No client with that ID.', 'easy-invoice' ),
293 [ 'status' => 400 ]
294 );
295 }
296 $data['client_id'] = $client_id;
297 }
298
299 $map = [
300 'number' => 'number',
301 'issue_date' => 'issue_date',
302 'due_date' => 'due_date',
303 'notes' => 'notes',
304 'terms' => 'terms_and_conditions',
305 'customer_name' => 'customer_name',
306 'customer_email' => 'customer_email',
307 'currency' => 'currency_code',
308 ];
309 foreach ( $map as $param => $field ) {
310 $value = $request->get_param( $param );
311 if ( null !== $value && '' !== $value ) {
312 $data[ $field ] = ( 'customer_email' === $param )
313 ? sanitize_email( (string) $value )
314 : sanitize_text_field( (string) $value );
315 }
316 }
317
318 $invoice = \EasyInvoice\Providers\InvoiceServiceProvider::getInvoiceRepository()->create( $data );
319 if ( ! $invoice || ! $invoice->getId() ) {
320 return new \WP_Error(
321 'easy_invoice_rest_create_failed',
322 __( 'The invoice could not be saved.', 'easy-invoice' ),
323 [ 'status' => 500 ]
324 );
325 }
326 $post_id = (int) $invoice->getId();
327
328 foreach ( [ 'customer_vat_number', 'customer_country' ] as $field ) {
329 $value = $request->get_param( $field );
330 if ( null !== $value ) {
331 update_post_meta( $post_id, '_easy_invoice_' . $field, sanitize_text_field( (string) $value ) );
332 }
333 }
334
335 $response = rest_ensure_response( self::shapeInvoice( new \EasyInvoice\Models\Invoice( get_post( $post_id ) ) ) );
336 $response->set_status( 201 );
337
338 return $response;
339 }
340
341 /**
342 * Delete an invoice.
343 *
344 * @param \WP_REST_Request $request Request.
345 * @return \WP_REST_Response|\WP_Error
346 */
347 public static function deleteInvoice( $request ) {
348 $post = self::documentOr404( (int) $request['id'], \EasyInvoice\Constants\PostTypes::EASY_INVOICE_POST_TYPE );
349 if ( is_wp_error( $post ) ) {
350 return $post;
351 }
352
353 // Trash rather than erase, matching what the admin screens do — an invoice
354 // is a financial record and a DELETE over HTTP should not be unrecoverable.
355 $result = wp_trash_post( $post->ID );
356
357 return rest_ensure_response( [
358 'deleted' => (bool) $result,
359 'id' => (int) $post->ID,
360 ] );
361 }
362
363 /**
364 * Return an invoice as a PDF.
365 *
366 * @param \WP_REST_Request $request Request.
367 * @return \WP_REST_Response|\WP_Error
368 */
369 public static function getInvoicePdf( $request ) {
370 $post = self::documentOr404( (int) $request['id'], \EasyInvoice\Constants\PostTypes::EASY_INVOICE_POST_TYPE );
371 if ( is_wp_error( $post ) ) {
372 return $post;
373 }
374
375 $pdf = \EasyInvoice\Services\PdfRenderer::renderInvoice( new \EasyInvoice\Models\Invoice( $post ) );
376 if ( is_wp_error( $pdf ) ) {
377 $pdf->add_data( [ 'status' => 500 ] );
378 return $pdf;
379 }
380
381 // Emit the file directly. Returning base64 in JSON would double the
382 // payload and force every client to decode it.
383 $number = (string) get_post_meta( $post->ID, '_easy_invoice_number', true );
384 $name = sanitize_file_name( ( $number !== '' ? $number : 'invoice-' . $post->ID ) . '.pdf' );
385
386 header( 'Content-Type: application/pdf' );
387 header( 'Content-Disposition: attachment; filename="' . $name . '"' );
388 header( 'Content-Length: ' . strlen( $pdf ) );
389 echo $pdf; // phpcs:ignore WordPress.Security.EscapeOutput -- binary PDF.
390 exit;
391 }
392
393 // ── Quotes and clients ───────────────────────────────────────────────
394
395 /**
396 * List quotes.
397 *
398 * @param \WP_REST_Request $request Request.
399 * @return \WP_REST_Response
400 */
401 public static function listQuotes( $request ) {
402 return self::listDocuments(
403 $request,
404 \EasyInvoice\Constants\PostTypes::EASY_INVOICE_QUOTE_POST_TYPE,
405 [ __CLASS__, 'shapeQuote' ]
406 );
407 }
408
409 /**
410 * Fetch one quote.
411 *
412 * @param \WP_REST_Request $request Request.
413 * @return \WP_REST_Response|\WP_Error
414 */
415 public static function getQuote( $request ) {
416 $post = self::documentOr404( (int) $request['id'], \EasyInvoice\Constants\PostTypes::EASY_INVOICE_QUOTE_POST_TYPE );
417 if ( is_wp_error( $post ) ) {
418 return $post;
419 }
420
421 return rest_ensure_response( self::shapeQuote( new \EasyInvoice\Models\Quote( $post ) ) );
422 }
423
424 /**
425 * List clients.
426 *
427 * @param \WP_REST_Request $request Request.
428 * @return \WP_REST_Response
429 */
430 public static function listClients( $request ) {
431 $per_page = min( 100, max( 1, (int) $request->get_param( 'per_page' ) ) );
432 $page = max( 1, (int) $request->get_param( 'page' ) );
433
434 $query = new \WP_User_Query( [
435 'number' => $per_page,
436 'paged' => $page,
437 'role__not_in' => [ 'Administrator' ],
438 'search' => $request->get_param( 'search' ) ? '*' . $request->get_param( 'search' ) . '*' : '',
439 'orderby' => 'ID',
440 'order' => 'DESC',
441 ] );
442
443 $clients = [];
444 foreach ( $query->get_results() as $user ) {
445 $clients[] = [
446 'id' => (int) $user->ID,
447 'name' => $user->display_name,
448 'email' => $user->user_email,
449 ];
450 }
451
452 $response = rest_ensure_response( $clients );
453 $response->header( 'X-WP-Total', (int) $query->get_total() );
454
455 return $response;
456 }
457
458 // ── Shared plumbing ──────────────────────────────────────────────────
459
460 /**
461 * List documents of a post type, paginated.
462 *
463 * @param \WP_REST_Request $request Request.
464 * @param string $post_type Post type.
465 * @param callable $shape Serialiser.
466 * @return \WP_REST_Response
467 */
468 private static function listDocuments( $request, string $post_type, callable $shape ) {
469 $per_page = min( 100, max( 1, (int) $request->get_param( 'per_page' ) ) );
470 $page = max( 1, (int) $request->get_param( 'page' ) );
471 $search = (string) $request->get_param( 'search' );
472
473 $query = new \WP_Query( [
474 'post_type' => $post_type,
475 'post_status' => [ 'publish', 'draft', 'pending', 'private' ],
476 'posts_per_page' => $per_page,
477 'paged' => $page,
478 's' => $search,
479 'orderby' => 'ID',
480 'order' => 'DESC',
481 ] );
482
483 $items = [];
484 foreach ( $query->posts as $post ) {
485 $model = ( \EasyInvoice\Constants\PostTypes::EASY_INVOICE_QUOTE_POST_TYPE === $post_type )
486 ? new \EasyInvoice\Models\Quote( $post )
487 : new \EasyInvoice\Models\Invoice( $post );
488 $items[] = call_user_func( $shape, $model );
489 }
490
491 $response = rest_ensure_response( $items );
492 $response->header( 'X-WP-Total', (int) $query->found_posts );
493 $response->header( 'X-WP-TotalPages', (int) $query->max_num_pages );
494
495 return $response;
496 }
497
498 /**
499 * Load a document of the expected type, or a 404.
500 *
501 * The 404 is deliberate for a wrong post type too: confirming that an id
502 * exists but is something else is information the caller has no need for.
503 *
504 * @param int $id Post ID.
505 * @param string $post_type Expected post type.
506 * @return \WP_Post|\WP_Error
507 */
508 private static function documentOr404( int $id, string $post_type ) {
509 $post = $id > 0 ? get_post( $id ) : null;
510
511 if ( ! $post || $post->post_type !== $post_type ) {
512 return new \WP_Error(
513 'easy_invoice_rest_not_found',
514 __( 'No document with that ID.', 'easy-invoice' ),
515 [ 'status' => 404 ]
516 );
517 }
518
519 return $post;
520 }
521
522 /**
523 * Serialise an invoice.
524 *
525 * Assembled field by field on purpose — see the class docblock. The
526 * per-document access token is a bearer credential for the public payment
527 * page and must never appear here.
528 *
529 * @param \EasyInvoice\Models\Invoice $invoice Invoice.
530 * @return array
531 */
532 public static function shapeInvoice( $invoice ): array {
533 $id = (int) $invoice->getId();
534 $data = [
535 'id' => $id,
536 'number' => (string) $invoice->getNumber(),
537 'title' => (string) $invoice->getTitle(),
538 'status' => (string) get_post_meta( $id, '_easy_invoice_status', true ),
539 'viewed' => \EasyInvoice\Services\DocumentViews::summary( $id ),
540 'issue_date' => (string) $invoice->getIssueDate(),
541 'due_date' => (string) $invoice->getDueDate(),
542 'customer' => [
543 'name' => (string) $invoice->getCustomerName(),
544 'email' => (string) $invoice->getCustomerEmail(),
545 'country' => (string) get_post_meta( $id, '_easy_invoice_customer_country', true ),
546 'vat' => (string) get_post_meta( $id, '_easy_invoice_customer_vat_number', true ),
547 ],
548 'totals' => [
549 'subtotal' => (float) $invoice->getSubtotal(),
550 'discount' => (float) $invoice->getDiscountAmount(),
551 'tax' => (float) $invoice->getTaxAmount(),
552 'total' => (float) $invoice->getTotal(),
553 'paid' => \EasyInvoice\Services\InvoiceBalance::paid( $id ),
554 'credited' => \EasyInvoice\Services\InvoiceBalance::credited( $id ),
555 'due' => \EasyInvoice\Services\InvoiceBalance::due( $invoice ),
556 ],
557 'items' => self::shapeItems( $invoice ),
558 'links' => [
559 'pdf' => rest_url( self::NAMESPACE . '/invoices/' . $id . '/pdf' ),
560 ],
561 ];
562
563 if ( class_exists( '\EasyInvoice\Services\TaxTreatment' ) ) {
564 $treatment = \EasyInvoice\Services\TaxTreatment::forDocument( $invoice );
565 $data['tax_treatment'] = [
566 'category' => $treatment['category'],
567 'statement' => \EasyInvoice\Services\TaxTreatment::statementFor( $invoice ),
568 ];
569 }
570
571 /**
572 * Filter the invoice representation returned by the REST API.
573 *
574 * @param array $data Serialised invoice.
575 * @param object $invoice Invoice model.
576 */
577 return (array) apply_filters( 'easy_invoice_rest_invoice', $data, $invoice );
578 }
579
580 /**
581 * Serialise a quote.
582 *
583 * @param \EasyInvoice\Models\Quote $quote Quote.
584 * @return array
585 */
586 public static function shapeQuote( $quote ): array {
587 $id = (int) $quote->getId();
588 $data = [
589 'id' => $id,
590 'number' => (string) get_post_meta( $id, '_easy_invoice_quote_number', true ),
591 'title' => is_callable( [ $quote, 'getTitle' ] ) ? (string) $quote->getTitle() : '',
592 'status' => is_callable( [ $quote, 'getStatus' ] ) ? (string) $quote->getStatus() : '',
593 'issue_date' => (string) get_post_meta( $id, '_easy_invoice_quote_issue_date', true ),
594 'expiry_date' => (string) get_post_meta( $id, '_easy_invoice_quote_expiry_date', true ),
595 'totals' => [
596 'total' => is_callable( [ $quote, 'getTotal' ] ) ? (float) $quote->getTotal() : 0.0,
597 ],
598 'items' => self::shapeItems( $quote ),
599 ];
600
601 /**
602 * Filter the quote representation returned by the REST API.
603 *
604 * @param array $data Serialised quote.
605 * @param object $quote Quote model.
606 */
607 return (array) apply_filters( 'easy_invoice_rest_quote', $data, $quote );
608 }
609
610 /**
611 * Serialise a document's line items.
612 *
613 * @param object $document Invoice or Quote model.
614 * @return array
615 */
616 private static function shapeItems( $document ): array {
617 if ( ! is_callable( [ $document, 'getItems' ] ) ) {
618 return [];
619 }
620
621 $out = [];
622 foreach ( (array) $document->getItems() as $item ) {
623 $out[] = [
624 'name' => is_callable( [ $item, 'getName' ] ) ? (string) $item->getName() : '',
625 'description' => is_callable( [ $item, 'getDescription' ] ) ? (string) $item->getDescription() : '',
626 'quantity' => is_callable( [ $item, 'getQuantity' ] ) ? (float) $item->getQuantity() : 0.0,
627 'price' => is_callable( [ $item, 'getPrice' ] ) ? (float) $item->getPrice() : 0.0,
628 'amount' => is_callable( [ $item, 'getAmount' ] ) ? (float) $item->getAmount() : 0.0,
629 'taxable' => is_callable( [ $item, 'isTaxable' ] ) ? (bool) $item->isTaxable() : true,
630 ];
631 }
632
633 return $out;
634 }
635
636 /**
637 * Clean line items arriving from a client.
638 *
639 * @param array $items Raw items.
640 * @return array
641 */
642 private static function sanitiseItems( array $items ): array {
643 $clean = [];
644
645 foreach ( $items as $item ) {
646 if ( ! is_array( $item ) ) {
647 continue;
648 }
649
650 $quantity = isset( $item['quantity'] ) ? (float) $item['quantity'] : 0.0;
651 $price = isset( $item['price'] ) ? (float) $item['price'] : 0.0;
652
653 $clean[] = [
654 'title' => sanitize_text_field( (string) ( $item['name'] ?? $item['title'] ?? '' ) ),
655 'description' => sanitize_textarea_field( (string) ( $item['description'] ?? '' ) ),
656 'quantity' => $quantity,
657 'price' => $price,
658 'adjust_percentage' => isset( $item['adjust_percentage'] ) ? (float) $item['adjust_percentage'] : 0.0,
659 'total' => $quantity * $price,
660 'taxable' => isset( $item['taxable'] ) ? (bool) $item['taxable'] : true,
661 'id' => 0,
662 ];
663 }
664
665 return $clean;
666 }
667 }
668