PluginProbe
Easy Invoice – Invoice Generator, PDF Quotes & Payments / 2.4.1
Easy Invoice – Invoice Generator, PDF Quotes & Payments v2.4.1
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
← All changes | includes/Controllers/QuoteController.php +280 -98 2.3.72.4.1 View file →
@@ -79,8 +79,10 @@
79 79 // Add AJAX handlers
80 80 add_action('wp_ajax_easy_invoice_delete_quote', [$this, 'handleDeleteQuote']);
81 81 add_action('wp_ajax_easy_invoice_get_quote', [$this, 'handleGetQuote']);
82 82 add_action('wp_ajax_easy_invoice_load_quote_template', [$this, 'handleLoadQuoteTemplate']);
83 + add_action('wp_ajax_easy_invoice_convert_quote', [$this, 'handleConvertQuote']);
84 + add_filter('easy_invoice_quote_row_actions', [$this, 'addConvertRowAction'], 5, 2);
83 85 add_action('wp_ajax_easy_invoice_create_new_quote', [$this, 'handleCreateNewQuote']);
84 86 // The `easy_invoice_search_clients` AJAX is owned by EasyInvoiceAjax.
85 87 // The duplicate registration that used to live here raced with
86 88 // EasyInvoiceAjax::searchClients() — only the first-registered
@@ -92,9 +94,8 @@
92 94 add_action('wp_ajax_easy_invoice_accept_quote', [$this, 'handleAcceptQuote']);
93 95 add_action('wp_ajax_easy_invoice_decline_quote', [$this, 'handleDeclineQuote']);
94 96 add_action('wp_ajax_nopriv_easy_invoice_accept_quote', [$this, 'handleAcceptQuote']);
95 97 add_action('wp_ajax_nopriv_easy_invoice_decline_quote', [$this, 'handleDeclineQuote']);
96 - add_action('wp_ajax_easy_invoice_update_existing_quotes', [$this, 'handleUpdateExistingQuotes']);
97 98
98 99 // Add missing AJAX handlers for quote listing actions
99 100 add_action('wp_ajax_easy_invoice_bulk_quote_action', [$this, 'handleBulkQuoteAction']);
100 101 add_action('wp_ajax_easy_invoice_trash_quote', [$this, 'handleTrashQuote']);
@@ -363,11 +364,12 @@
363 364 ]
364 365 ]);
365 366 $meta_search = new \WP_Query($meta_search_args);
366 367
367 - if ($meta_search->have_posts()) {
368 - $search_ids = array_merge($search_ids, wp_list_pluck($meta_search->posts, 'ID'));
369 - }
368 + // 'fields' => 'ids' above: $posts already holds ids. Plucking 'ID' off
369 + // integers produced nulls, so a search by quote number, client name or
370 + // email matched nothing.
371 + $search_ids = array_map('intval', array_merge($search_ids, (array) $meta_search->posts));
370 372
371 373 $search_ids = array_unique($search_ids);
372 374
373 375 if (!empty($search_ids)) {
@@ -506,9 +508,12 @@
506 508 if ($quote_id > 0) {
507 509 $quote = $this->quote_repository->find($quote_id);
508 510 }
509 511
510 - $clients = $this->client_repository->all();
512 + // The builder's picker searches over AJAX; the hidden mirror select only needs
513 + // the quote's own client (rendered by the form). Loading every client here
514 + // built a model per user on each open.
515 + $clients = [];
511 516
512 517 // Allow plugins to modify the data
513 518 $quote = apply_filters('easy_invoice_quote_controller_builder_quote', $quote, $quote_id);
514 519 $clients = apply_filters('easy_invoice_quote_controller_builder_clients', $clients);
@@ -532,14 +537,14 @@
532 537
533 538 $quote_id = isset($_GET['id']) ? (int) $_GET['id'] : 0;
534 539
535 540 if ($quote_id <= 0) {
536 - wp_die(__('Quote not found.', 'easy-invoice'));
541 + wp_die(esc_html__('Quote not found.', 'easy-invoice'));
537 542 }
538 543
539 544 $quote = $this->quote_repository->find($quote_id);
540 545 if (!$quote) {
541 - wp_die(__('Quote not found.', 'easy-invoice'));
546 + wp_die(esc_html__('Quote not found.', 'easy-invoice'));
542 547 }
543 548
544 549 // Allow plugins to modify the quote
545 550 $quote = apply_filters('easy_invoice_quote_controller_preview_quote', $quote, $quote_id);
@@ -652,13 +657,25 @@
652 657 if (!$template_file) {
653 658 wp_send_json_error(['message' => __('Template not found.', 'easy-invoice')]);
654 659 }
655 660
656 - // Load quote if provided
657 - $quote = null;
661 + // Load quote if provided.
662 + //
663 + // For an unsaved quote there is no id, and the quote design templates call
664 + // $quote->getTitle() / getNumber() / etc. unguarded — passing null made
665 + // previewing or switching a template on a new quote fatal, the same way it
666 + // did on the invoice side (see InvoiceController::handleLoadTemplate). The
667 + // model's constructor accepts null and fills itself from the field defaults,
668 + // so an empty instance renders a blank preview instead.
669 + $quote = new \EasyInvoice\Models\Quote();
658 670 if ($quote_id > 0) {
659 - $quote = $this->quote_repository->find($quote_id);
671 + $loaded = $this->quote_repository->find($quote_id);
672 + if ($loaded) {
673 + $quote = $loaded;
674 + }
660 675 }
676 + // Unsaved edits from the builder take precedence over the stored values.
677 + $quote = \EasyInvoice\Helpers\PreviewOverlay::apply($quote, isset($_POST['form_data']) ? (string) wp_unslash($_POST['form_data']) : '', 'quote');
661 678
662 679 // Start output buffering to capture template HTML
663 680 ob_start();
664 681
@@ -813,10 +830,10 @@
813 830 $data = [
814 831 'title' => $title,
815 832 'status' => 'draft',
816 833 'number' => $quote_number, // Use the generated unique number
817 - 'issue_date' => date('Y-m-d'),
818 - 'expiry_date' => date('Y-m-d', strtotime('+30 days')),
834 + 'issue_date' => current_time('Y-m-d'),
835 + 'expiry_date' => wp_date('Y-m-d', strtotime('+30 days')),
819 836 'items' => [],
820 837 'notes' => '', // Ensure notes is never null
821 838 'terms' => $quote_terms, // Use global terms setting
822 839 'footer_text' => $quote_footer, // Use global footer setting
@@ -865,10 +882,10 @@
865 882 // Create a new quote object for the form
866 883 $quote_number_service = function_exists('easy_invoice_get_quote_number_service') ? easy_invoice_get_quote_number_service() : null;
867 884 $quote_data = array(
868 885 'number' => $quote_number_service ? $quote_number_service->getNextNumber() : 'QT-1',
869 - 'date' => date('Y-m-d'),
870 - 'expiry_date' => date('Y-m-d', strtotime('+30 days')),
886 + 'date' => current_time('Y-m-d'),
887 + 'expiry_date' => wp_date('Y-m-d', strtotime('+30 days')),
871 888 'client_id' => 0,
872 889 'client_name' => '',
873 890 'client_email' => '',
874 891 'client_phone' => '',
@@ -952,9 +969,9 @@
952 969 $quote->setItems([]);
953 970
954 971 // Set variables needed by the form template
955 972 $quote_id = 0;
956 - $clients = \EasyInvoice\Providers\ClientServiceProvider::getClientRepository()->all();
973 + $clients = [];
957 974 $quote_form_manager = new \EasyInvoice\Forms\Quote\QuoteFormManager();
958 975 $quote_items_json = json_encode([]);
959 976 $admin_nonce = wp_create_nonce('easy_invoice_admin_nonce');
960 977 $quote_field_config = $quote_form_manager->getFieldConfigForJavaScript();
@@ -1043,9 +1060,10 @@
1043 1060 $token = sanitize_text_field(wp_unslash($_POST['access_token']));
1044 1061 } elseif (isset($_GET['qk'])) {
1045 1062 $token = sanitize_text_field(wp_unslash($_GET['qk']));
1046 1063 }
1047 - return $token;
1064 + /** This filter is documented in includes/Controllers/InvoiceController.php */
1065 + return (string) apply_filters('easy_invoice_presented_access_token', $token, 'quote');
1048 1066 }
1049 1067
1050 1068 /**
1051 1069 * Central authorisation check for quote accept/decline. Returns true
@@ -1064,8 +1082,41 @@
1064 1082 * Returns false otherwise. Callers must reject the request when this
1065 1083 * returns false; we don't reject from in here so the caller can choose
1066 1084 * wp_send_json_error vs wp_die based on its transport.
1067 1085 */
1086 + /**
1087 + * Whether a quote can still be accepted or declined: it must be open
1088 + * (draft, available or sent) and not past its expiry date.
1089 + *
1090 + * @param object $quote Quote model.
1091 + * @return true|\WP_Error Error carrying the reason to show the client.
1092 + */
1093 + public static function openForDecision($quote) {
1094 + $status = is_callable([$quote, 'getStatus']) ? strtolower((string) $quote->getStatus()) : '';
1095 + if ('accepted' === $status) {
1096 + return new \WP_Error('easy_invoice_quote_closed', __('This quote has already been accepted.', 'easy-invoice'));
1097 + }
1098 + if ('declined' === $status) {
1099 + return new \WP_Error('easy_invoice_quote_closed', __('This quote has already been declined.', 'easy-invoice'));
1100 + }
1101 + if (!in_array($status, ['draft', 'available', 'sent', 'expired'], true)) {
1102 + return new \WP_Error('easy_invoice_quote_closed', __('This quote is no longer open.', 'easy-invoice'));
1103 + }
1104 + $expiry = is_callable([$quote, 'getExpiryDate']) ? (string) $quote->getExpiryDate() : '';
1105 + $expired = 'expired' === $status
1106 + || ('' !== $expiry && strtotime($expiry) && gmdate('Y-m-d', strtotime($expiry)) < gmdate('Y-m-d', current_time('timestamp')));
1107 + if ($expired) {
1108 + return new \WP_Error(
1109 + 'easy_invoice_quote_expired',
1110 + '' !== $expiry
1111 + /* translators: %s: expiry date. */
1112 + ? sprintf(__('This quote expired on %s. Please ask for a new one.', 'easy-invoice'), date_i18n(get_option('date_format'), strtotime($expiry)))
1113 + : __('This quote has expired. Please ask for a new one.', 'easy-invoice')
1114 + );
1115 + }
1116 + return true;
1117 + }
1118 +
1068 1119 public static function canActOnQuote(int $quote_id, $quote = null): bool {
1069 1120 if ($quote_id <= 0) {
1070 1121 return false;
1071 1122 }
@@ -1144,8 +1195,13 @@
1144 1195 if (!self::canActOnQuote($quote_id, $quote)) {
1145 1196 wp_send_json_error(['message' => __('You do not have permission to accept this quote.', 'easy-invoice')]);
1146 1197 }
1147 1198
1199 + $ei_open = self::openForDecision($quote);
1200 + if (is_wp_error($ei_open)) {
1201 + wp_send_json_error(['message' => $ei_open->get_error_message()]);
1202 + }
1203 +
1148 1204 $current_user = wp_get_current_user();
1149 1205
1150 1206 // Get global accept action setting
1151 1207 $settings_controller = new \EasyInvoice\Controllers\SettingsController();
@@ -1152,9 +1208,9 @@
1152 1208 $accept_action = $settings_controller::getQuoteAcceptAction();
1153 1209
1154 1210 // Update quote status to accepted
1155 1211 $quote->setStatus('accepted');
1156 - $quote->setAcceptedDate(date('Y-m-d H:i:s'));
1212 + $quote->setAcceptedDate(gmdate('Y-m-d H:i:s'));
1157 1213 $quote->setAcceptedBy($current_user->ID);
1158 1214
1159 1215 // Save the quote
1160 1216 $saved = $quote->save();
@@ -1168,8 +1224,33 @@
1168 1224 'accept_action' => $accept_action,
1169 1225 'user_type' => $is_admin ? 'admin' : 'client'
1170 1226 ]);
1171 1227
1228 + // What the acceptance was made with. The signature is a data-URL PNG
1229 + // from the page's signature pad (only present when an addon asked for
1230 + // it); it is validated here and stored by whoever listens.
1231 + $signature = isset($_POST['signature']) ? (string) wp_unslash($_POST['signature']) : ''; // phpcs:ignore WordPress.Security.ValidatedSanitizedInput -- validated below.
1232 + if ('' !== $signature && !preg_match('#^data:image/png;base64,[A-Za-z0-9+/=]+$#', $signature)) {
1233 + $signature = '';
1234 + }
1235 + /**
1236 + * Fires once a quote has been accepted and saved.
1237 + *
1238 + * @param int $quote_id Quote id.
1239 + * @param object $quote Quote model.
1240 + * @param array $context accept_action, user_type, signature (data URL or ''),
1241 + * signer_name, ip, user_agent, accepted_at.
1242 + */
1243 + do_action('easy_invoice_quote_accepted', $quote_id, $quote, [
1244 + 'accept_action' => $accept_action,
1245 + 'user_type' => $is_admin ? 'admin' : 'client',
1246 + 'signature' => $signature,
1247 + 'signer_name' => isset($_POST['signer_name']) ? sanitize_text_field(wp_unslash($_POST['signer_name'])) : '',
1248 + 'ip' => isset($_SERVER['REMOTE_ADDR']) ? sanitize_text_field(wp_unslash($_SERVER['REMOTE_ADDR'])) : '',
1249 + 'user_agent' => isset($_SERVER['HTTP_USER_AGENT']) ? sanitize_text_field(wp_unslash($_SERVER['HTTP_USER_AGENT'])) : '',
1250 + 'accepted_at' => current_time('mysql'),
1251 + ]);
1252 +
1172 1253 // Perform the configured accept action
1173 1254 $invoice_id = null;
1174 1255 $action_message = '';
1175 1256
@@ -1265,8 +1346,82 @@
1265 1346 * @param \EasyInvoice\Models\Quote $quote The quote to convert
1266 1347 * @param string $status The status for the new invoice ('draft' or 'available')
1267 1348 * @return int|null The invoice ID if successful, null otherwise
1268 1349 */
1350 + /**
1351 + * "Convert to invoice" on the quote row — for the quote the client accepted
1352 + * by phone or in person, which the public Accept button never sees.
1353 + *
1354 + * @param array $actions Row actions.
1355 + * @param object $quote Quote model.
1356 + * @return array
1357 + */
1358 + public function addConvertRowAction($actions, $quote): array {
1359 + $actions = is_array($actions) ? $actions : [];
1360 + if (!easy_invoice_user_can('ei_create_invoice') || !is_callable([$quote, 'getId'])) {
1361 + return $actions;
1362 + }
1363 + $converted = (int) get_post_meta((int) $quote->getId(), '_easy_invoice_quote_converted_invoice_id', true);
1364 + if ($converted > 0 && get_post($converted)) {
1365 + $actions['convert'] = sprintf(
1366 + '<a href="%s" class="text-emerald-700 font-semibold" title="%s">%s</a>',
1367 + esc_url(admin_url('admin.php?page=easy-invoice-builder&invoice_id=' . $converted)),
1368 + esc_attr__('Open the invoice made from this quote', 'easy-invoice'),
1369 + esc_html__('Invoice', 'easy-invoice')
1370 + );
1371 + return $actions;
1372 + }
1373 + $actions['convert'] = sprintf(
1374 + '<a href="#" class="convert-quote text-indigo-600 font-semibold" data-quote-id="%d" data-quote-number="%s">%s</a>',
1375 + (int) $quote->getId(),
1376 + esc_attr((string) $quote->getNumber()),
1377 + esc_html__('Convert to invoice', 'easy-invoice')
1378 + );
1379 + return $actions;
1380 + }
1381 +
1382 + /**
1383 + * AJAX: make a draft invoice from a quote and mark the quote accepted.
1384 + */
1385 + public function handleConvertQuote(): void {
1386 + if (!isset($_POST['nonce']) || !wp_verify_nonce(sanitize_text_field(wp_unslash($_POST['nonce'])), 'easy_invoice_admin_nonce')) {
1387 + wp_send_json_error(['message' => __('Security check failed. Please reload the page and try again.', 'easy-invoice')]);
1388 + }
1389 + if (!easy_invoice_user_can('ei_create_invoice')) {
1390 + wp_send_json_error(['message' => __('You do not have permission to create invoices.', 'easy-invoice')]);
1391 + }
1392 + $quote_id = isset($_POST['quote_id']) ? absint($_POST['quote_id']) : 0;
1393 + $quote = $quote_id > 0 ? $this->quote_repository->find($quote_id) : null;
1394 + if (!$quote) {
1395 + wp_send_json_error(['message' => __('Quote not found.', 'easy-invoice')]);
1396 + }
1397 + $existing = (int) get_post_meta($quote_id, '_easy_invoice_quote_converted_invoice_id', true);
1398 + if ($existing > 0 && get_post($existing)) {
1399 + wp_send_json_success(['invoice_id' => $existing, 'already' => true, 'message' => __('This quote already has an invoice.', 'easy-invoice')]);
1400 + }
1401 + $invoice_id = $this->convertQuoteToInvoice($quote, 'draft');
1402 + if (!$invoice_id) {
1403 + wp_send_json_error(['message' => __('The invoice could not be created.', 'easy-invoice')]);
1404 + }
1405 + update_post_meta($quote_id, '_easy_invoice_quote_converted_invoice_id', $invoice_id);
1406 + update_post_meta($invoice_id, '_easy_invoice_converted_from_quote', $quote_id);
1407 + if (!in_array((string) $quote->getStatus(), ['accepted', 'declined', 'cancelled'], true)) {
1408 + update_post_meta($quote_id, '_easy_invoice_quote_status', 'accepted');
1409 + }
1410 + /**
1411 + * Fires after an administrator converts a quote into an invoice by hand.
1412 + *
1413 + * @param int $quote_id Quote.
1414 + * @param int $invoice_id New draft invoice.
1415 + */
1416 + do_action('easy_invoice_quote_converted_manually', $quote_id, $invoice_id);
1417 + wp_send_json_success([
1418 + 'invoice_id' => $invoice_id,
1419 + 'message' => __('Draft invoice created from the quote.', 'easy-invoice'),
1420 + 'redirect' => admin_url('admin.php?page=easy-invoice-builder&invoice_id=' . $invoice_id),
1421 + ]);
1422 + }
1423 +
1269 1424 private function convertQuoteToInvoice($quote, $status = 'draft'): ?int {
1270 1425 try {
1271 1426 // Get invoice repository
1272 1427 $invoice_repository = \EasyInvoice\Providers\InvoiceServiceProvider::getInvoiceRepository();
@@ -1275,10 +1430,10 @@
1275 1430 $invoice_data = [
1276 1431 'title' => $quote->getTitle() ?: 'Invoice from Quote ' . $quote->getNumber(),
1277 1432 'number' => $this->generateInvoiceNumber(),
1278 1433 'status' => $status,
1279 - 'issue_date' => date('Y-m-d'),
1280 - 'due_date' => date('Y-m-d', strtotime('+30 days')),
1434 + 'issue_date' => current_time('Y-m-d'),
1435 + 'due_date' => wp_date('Y-m-d', strtotime('+30 days')),
1281 1436 'client_id' => $quote->getClientId(),
1282 1437 'customer_name' => $quote->getCustomerName(),
1283 1438 'customer_email' => $quote->getCustomerEmail(),
1284 1439 'customer_address' => $quote->getCustomerAddress(),
@@ -1293,8 +1448,9 @@
1293 1448 'payment_gateways' => [], // Invoice-specific field, leave empty
1294 1449 'template' => $quote->getTemplate(),
1295 1450 'subtotal' => $quote->getSubtotal(),
1296 1451 'tax_rate' => $quote->getTaxRate(),
1452 + 'tax_enabled' => $quote->getTaxEnabled() ?: (get_option('easy_invoice_tax_enabled', 'no') === 'yes' ? 'yes' : 'no'),
1297 1453 'tax_amount' => $quote->getTaxAmount(),
1298 1454 'discount_type' => $quote->getDiscountType(),
1299 1455 'discount_value' => $quote->getDiscountValue(),
1300 1456 'discount_amount' => $quote->getDiscountAmount(),
@@ -1306,8 +1462,17 @@
1306 1462 'prices_include_tax' => $quote->getPricesIncludeTax(),
1307 1463 'custom_fields' => $quote->getCustomFields(), // Transfer custom fields
1308 1464 ];
1309 1465
1466 + /**
1467 + * Filter the data an invoice is created from when a quote is
1468 + * converted, so addons can carry their own quote fields across.
1469 + *
1470 + * @param array $invoice_data
1471 + * @param Quote $quote
1472 + */
1473 + $invoice_data = apply_filters('easy_invoice_quote_to_invoice_data', $invoice_data, $quote);
1474 +
1310 1475 // Create the invoice
1311 1476 $invoice = $invoice_repository->create($invoice_data);
1312 1477
1313 1478 if ($invoice) {
@@ -1312,17 +1477,30 @@
1312 1477
1313 1478 if ($invoice) {
1314 1479 // Store the quote ID in the invoice's meta for tracking
1315 1480 update_post_meta($invoice->getId(), '_converted_from_quote', $quote->getId());
1481 + update_post_meta($invoice->getId(), '_easy_invoice_converted_from_quote', $quote->getId());
1316 1482
1317 - // Update quote to reference the created invoice
1483 + // Update quote to reference the created invoice — the same key
1484 + // the quote list and "convert" guard read, whichever path
1485 + // (manual convert, accept-and-convert) produced the invoice.
1486 + update_post_meta($quote->getId(), '_easy_invoice_quote_converted_invoice_id', (int) $invoice->getId());
1318 1487 $quote->setCustomField('converted_invoice_id', $invoice->getId());
1319 1488 $quote->save();
1320 1489
1321 1490 // Ensure secure link is generated for the new invoice (Pro version)
1322 1491 if (class_exists('\EasyInvoicePro\Addons\SecureLinks\Controllers\PermalinkController')) {
1323 - // Trigger the save_post hook to generate secure link
1324 - do_action('save_post_easy_invoice', $invoice->getId(), get_post($invoice->getId()));
1492 + // Trigger the save_post hook to generate secure link.
1493 + //
1494 + // Core's save_post_{post_type} passes three arguments — $post_id,
1495 + // $post and $update — and callbacks are written against that
1496 + // signature. Firing it with two put a client-facing fatal on the
1497 + // quote-acceptance path: Team Roles' audit logger declares all three
1498 + // as required, so accepting a quote raised ArgumentCountError and
1499 + // the customer got "There has been a critical error on this website"
1500 + // after the invoice had already been created. Passing `true` for
1501 + // $update because the invoice row exists by this point.
1502 + do_action('save_post_easy_invoice', $invoice->getId(), get_post($invoice->getId()), true);
1325 1503 }
1326 1504
1327 1505 return $invoice->getId();
1328 1506 }
@@ -1350,10 +1528,10 @@
1350 1528 $invoice_data = [
1351 1529 'title' => 'Invoice from Quote ' . $quote->getNumber(),
1352 1530 'number' => $this->generateInvoiceNumber(),
1353 1531 'status' => $status,
1354 - 'issue_date' => date('Y-m-d'),
1355 - 'due_date' => date('Y-m-d', strtotime('+30 days')),
1532 + 'issue_date' => current_time('Y-m-d'),
1533 + 'due_date' => wp_date('Y-m-d', strtotime('+30 days')),
1356 1534 'client_id' => $quote->getClientId(),
1357 1535 'customer_name' => $quote->getCustomerName(),
1358 1536 'customer_email' => $quote->getCustomerEmail(),
1359 1537 'customer_address' => $quote->getCustomerAddress(),
@@ -1368,8 +1546,9 @@
1368 1546 'payment_gateways' => [], // Invoice-specific field, leave empty
1369 1547 'template' => $quote->getTemplate(),
1370 1548 'subtotal' => $quote->getSubtotal(),
1371 1549 'tax_rate' => $quote->getTaxRate(),
1550 + 'tax_enabled' => $quote->getTaxEnabled() ?: (get_option('easy_invoice_tax_enabled', 'no') === 'yes' ? 'yes' : 'no'),
1372 1551 'tax_amount' => $quote->getTaxAmount(),
1373 1552 'discount_type' => $quote->getDiscountType(),
1374 1553 'discount_value' => $quote->getDiscountValue(),
1375 1554 'discount_amount' => $quote->getDiscountAmount(),
@@ -1381,8 +1560,17 @@
1381 1560 'prices_include_tax' => $quote->getPricesIncludeTax(),
1382 1561 'custom_fields' => $quote->getCustomFields(), // Transfer custom fields
1383 1562 ];
1384 1563
1564 + /**
1565 + * Filter the data an invoice is created from when a quote is
1566 + * converted, so addons can carry their own quote fields across.
1567 + *
1568 + * @param array $invoice_data
1569 + * @param Quote $quote
1570 + */
1571 + $invoice_data = apply_filters('easy_invoice_quote_to_invoice_data', $invoice_data, $quote);
1572 +
1385 1573 // Create the invoice
1386 1574 $invoice = $invoice_repository->create($invoice_data);
1387 1575
1388 1576 if ($invoice) {
@@ -1391,10 +1579,19 @@
1391 1579 $quote->save();
1392 1580
1393 1581 // Ensure secure link is generated for the new invoice (Pro version)
1394 1582 if (class_exists('\EasyInvoicePro\Addons\SecureLinks\Controllers\PermalinkController')) {
1395 - // Trigger the save_post hook to generate secure link
1396 - do_action('save_post_easy_invoice', $invoice->getId(), get_post($invoice->getId()));
1583 + // Trigger the save_post hook to generate secure link.
1584 + //
1585 + // Core's save_post_{post_type} passes three arguments — $post_id,
1586 + // $post and $update — and callbacks are written against that
1587 + // signature. Firing it with two put a client-facing fatal on the
1588 + // quote-acceptance path: Team Roles' audit logger declares all three
1589 + // as required, so accepting a quote raised ArgumentCountError and
1590 + // the customer got "There has been a critical error on this website"
1591 + // after the invoice had already been created. Passing `true` for
1592 + // $update because the invoice row exists by this point.
1593 + do_action('save_post_easy_invoice', $invoice->getId(), get_post($invoice->getId()), true);
1397 1594 }
1398 1595
1399 1596 return $invoice->getId();
1400 1597 }
@@ -1445,16 +1642,26 @@
1445 1642 $invoice_items = [];
1446 1643
1447 1644 foreach ($quote_items as $quote_item) {
1448 1645 if (is_object($quote_item) && method_exists($quote_item, 'toArray')) {
1449 - // Convert QuoteItem object to InvoiceItem array
1646 + // A saved quote stores its lines as title/total, an invoice as
1647 + // name/amount; read through the model, which knows both, or
1648 + // the converted invoice has nameless lines that add up to 0.
1450 1649 $item_data = $quote_item->toArray();
1650 + $name = (string) (is_callable([$quote_item, 'getName']) ? $quote_item->getName() : '');
1651 + if ('' === $name) {
1652 + $name = (string) ($item_data['name'] ?? $item_data['title'] ?? '');
1653 + }
1654 + $amount = $item_data['amount'] ?? $item_data['total'] ?? null;
1655 + if (null === $amount || '' === $amount) {
1656 + $amount = is_callable([$quote_item, 'getAmount']) ? $quote_item->getAmount() : (float) ($item_data['quantity'] ?? 0) * (float) ($item_data['price'] ?? 0);
1657 + }
1451 1658 $invoice_items[] = [
1452 - 'name' => $item_data['name'] ?? '',
1659 + 'name' => $name,
1453 1660 'description' => $item_data['description'] ?? '',
1454 1661 'quantity' => $item_data['quantity'] ?? 0,
1455 1662 'price' => $item_data['price'] ?? 0,
1456 - 'amount' => $item_data['amount'] ?? 0,
1663 + 'amount' => $amount,
1457 1664 'taxable' => $item_data['taxable'] ?? true,
1458 1665 // Map adjust_percentage to a similar field if needed
1459 1666 'adjust_percentage' => $item_data['adjust_percentage'] ?? 0,
1460 1667 ];
@@ -1571,13 +1778,18 @@
1571 1778 if (!self::canActOnQuote($quote_id, $quote)) {
1572 1779 wp_send_json_error(['message' => __('You do not have permission to decline this quote.', 'easy-invoice')]);
1573 1780 }
1574 1781
1782 + $ei_open = self::openForDecision($quote);
1783 + if (is_wp_error($ei_open)) {
1784 + wp_send_json_error(['message' => $ei_open->get_error_message()]);
1785 + }
1786 +
1575 1787 $current_user = wp_get_current_user();
1576 1788
1577 1789 // Update quote status to declined
1578 1790 $quote->setStatus('declined');
1579 - $quote->setDeclinedDate(date('Y-m-d H:i:s'));
1791 + $quote->setDeclinedDate(gmdate('Y-m-d H:i:s'));
1580 1792 $quote->setDeclinedBy($current_user->ID);
1581 1793
1582 1794 // Save decline reason if provided
1583 1795 if (!empty($decline_reason)) {
@@ -1631,57 +1843,9 @@
1631 1843 $email_manager = \EasyInvoice\Services\EmailManager::getInstance();
1632 1844 $email_manager->sendAdminQuoteNotification($quote, 'declined');
1633 1845 }
1634 1846
1635 - /**
1636 - * Handle AJAX request to update existing quotes with missing data
1637 - *
1638 - * @since 1.0.0
1639 - */
1640 - public function handleUpdateExistingQuotes(): void {
1641 - // Verify nonce - match the nonce being sent from JavaScript
1642 - if (!wp_verify_nonce($_POST['nonce'] ?? '', 'easy_invoice_admin_nonce')) {
1643 - wp_send_json_error(['message' => __('Security check failed.', 'easy-invoice')]);
1644 - }
1645 1847
1646 - // Check permissions — bulk migration / repair: admin-only.
1647 - if (!current_user_can('manage_options')) {
1648 - wp_send_json_error(['message' => __('You do not have permission to perform this action.', 'easy-invoice')]);
1649 - }
1650 -
1651 - $updated_count = 0;
1652 - $quotes = $this->quote_repository->findAll();
1653 -
1654 - foreach ($quotes as $quote) {
1655 - $post = get_post($quote->getId());
1656 - if ($post && empty($post->post_name)) {
1657 - // Generate a proper slug for this quote
1658 - $post_title = $quote->getTitle() ?: $quote->getNumber() ?: 'Untitled Quote';
1659 - $post_name = sanitize_title($post_title);
1660 -
1661 - // Ensure uniqueness
1662 - $original_slug = $post_name;
1663 - $counter = 1;
1664 - while (get_page_by_path($post_name, OBJECT, \EasyInvoice\Constants\PostTypes::EASY_INVOICE_QUOTE_POST_TYPE)) {
1665 - $post_name = $original_slug . '-' . $counter;
1666 - $counter++;
1667 - }
1668 -
1669 - // Update the post with the new slug
1670 - wp_update_post([
1671 - 'ID' => $quote->getId(),
1672 - 'post_name' => $post_name
1673 - ]);
1674 -
1675 - $updated_count++;
1676 - }
1677 - }
1678 -
1679 - wp_send_json_success([
1680 - 'message' => sprintf(__('Updated %d quotes with proper URLs.', 'easy-invoice'), $updated_count)
1681 - ]);
1682 - }
1683 -
1684 1848 /**
1685 1849 * Handle AJAX request to duplicate a quote
1686 1850 *
1687 1851 * @since 1.0.0
@@ -1723,10 +1887,10 @@
1723 1887 $duplicate_data = [
1724 1888 'title' => $quote->getTitle() . ' (Copy)',
1725 1889 'status' => 'draft',
1726 1890 'number' => $this->generateInvoiceNumber(), // Use invoice number service for consistency
1727 - 'issue_date' => date('Y-m-d'),
1728 - 'expiry_date' => date('Y-m-d', strtotime('+30 days')),
1891 + 'issue_date' => current_time('Y-m-d'),
1892 + 'expiry_date' => wp_date('Y-m-d', strtotime('+30 days')),
1729 1893 'items' => $this->convertQuoteItemsToInvoiceItems($quote->getItems()), // Use invoice item conversion
1730 1894 'notes' => $quote->getNotes(),
1731 1895 'description' => $quote->getDescription(),
1732 1896 'terms' => $quote_terms,
@@ -1788,13 +1952,13 @@
1788 1952 private function handleAcceptQuoteForm(): void {
1789 1953 $quote_id = isset($_POST['quote_id']) ? (int) $_POST['quote_id'] : 0;
1790 1954
1791 1955 if ($quote_id <= 0) {
1792 - wp_die(__('Invalid quote ID.', 'easy-invoice'));
1956 + wp_die(esc_html__('Invalid quote ID.', 'easy-invoice'));
1793 1957 }
1794 1958
1795 1959 if (!wp_verify_nonce($_POST['quote_nonce'] ?? '', $this->quoteAcceptDeclineNonceAction($quote_id))) {
1796 - wp_die(__('Security check failed.', 'easy-invoice'));
1960 + wp_die(esc_html__('Security check failed.', 'easy-invoice'));
1797 1961 }
1798 1962
1799 1963 $current_user = wp_get_current_user();
1800 1964 $is_admin = current_user_can('manage_options');
@@ -1805,20 +1969,25 @@
1805 1969 $quote = $this->quote_repository->findPublished($quote_id);
1806 1970 }
1807 1971
1808 1972 if (!$quote) {
1809 - wp_die(__('Quote not found.', 'easy-invoice'));
1973 + wp_die(esc_html__('Quote not found.', 'easy-invoice'));
1810 1974 }
1811 1975
1812 1976 // SECURITY (CVE-2026-9021): unconditional authorisation. See
1813 1977 // handleAcceptQuote (AJAX path) for full rationale.
1814 1978 if (!self::canActOnQuote($quote_id, $quote)) {
1815 - wp_die(__('You do not have permission to accept this quote.', 'easy-invoice'));
1979 + wp_die(esc_html__('You do not have permission to accept this quote.', 'easy-invoice'));
1816 1980 }
1817 1981
1982 + $ei_open = self::openForDecision($quote);
1983 + if (is_wp_error($ei_open)) {
1984 + wp_die(esc_html($ei_open->get_error_message()));
1985 + }
1986 +
1818 1987 // Update quote status to accepted
1819 1988 $quote->setStatus('accepted');
1820 - $quote->setAcceptedDate(date('Y-m-d H:i:s'));
1989 + $quote->setAcceptedDate(gmdate('Y-m-d H:i:s'));
1821 1990 $quote->setAcceptedBy($current_user->ID);
1822 1991
1823 1992 // Save the quote
1824 1993 $saved = $quote->save();
@@ -1823,9 +1992,9 @@
1823 1992 // Save the quote
1824 1993 $saved = $quote->save();
1825 1994
1826 1995 if (!$saved) {
1827 - wp_die(__('Failed to accept quote.', 'easy-invoice'));
1996 + wp_die(esc_html__('Failed to accept quote.', 'easy-invoice'));
1828 1997 }
1829 1998
1830 1999 // Send notification email to admin
1831 2000 if (!$is_admin) {
@@ -1833,9 +2002,9 @@
1833 2002 }
1834 2003
1835 2004 // Redirect back to the quote page with success message
1836 2005 $redirect_url = add_query_arg('action', 'accepted', get_permalink($quote_id));
1837 - wp_redirect($redirect_url);
2006 + wp_safe_redirect($redirect_url);
1838 2007 exit;
1839 2008 }
1840 2009
1841 2010 /**
@@ -1846,13 +2015,13 @@
1846 2015 private function handleDeclineQuoteForm(): void {
1847 2016 $quote_id = isset($_POST['quote_id']) ? (int) $_POST['quote_id'] : 0;
1848 2017
1849 2018 if ($quote_id <= 0) {
1850 - wp_die(__('Invalid quote ID.', 'easy-invoice'));
2019 + wp_die(esc_html__('Invalid quote ID.', 'easy-invoice'));
1851 2020 }
1852 2021
1853 2022 if (!wp_verify_nonce($_POST['quote_nonce'] ?? '', $this->quoteAcceptDeclineNonceAction($quote_id))) {
1854 - wp_die(__('Security check failed.', 'easy-invoice'));
2023 + wp_die(esc_html__('Security check failed.', 'easy-invoice'));
1855 2024 }
1856 2025
1857 2026 $current_user = wp_get_current_user();
1858 2027 $is_admin = current_user_can('manage_options');
@@ -1863,20 +2032,25 @@
1863 2032 $quote = $this->quote_repository->findPublished($quote_id);
1864 2033 }
1865 2034
1866 2035 if (!$quote) {
1867 - wp_die(__('Quote not found.', 'easy-invoice'));
2036 + wp_die(esc_html__('Quote not found.', 'easy-invoice'));
1868 2037 }
1869 2038
1870 2039 // SECURITY (CVE-2026-9021): unconditional authorisation. See
1871 2040 // handleAcceptQuote (AJAX path) for full rationale.
1872 2041 if (!self::canActOnQuote($quote_id, $quote)) {
1873 - wp_die(__('You do not have permission to decline this quote.', 'easy-invoice'));
2042 + wp_die(esc_html__('You do not have permission to decline this quote.', 'easy-invoice'));
1874 2043 }
1875 2044
2045 + $ei_open = self::openForDecision($quote);
2046 + if (is_wp_error($ei_open)) {
2047 + wp_die(esc_html($ei_open->get_error_message()));
2048 + }
2049 +
1876 2050 // Update quote status to declined
1877 2051 $quote->setStatus('declined');
1878 - $quote->setDeclinedDate(date('Y-m-d H:i:s'));
2052 + $quote->setDeclinedDate(gmdate('Y-m-d H:i:s'));
1879 2053 $quote->setDeclinedBy($current_user->ID);
1880 2054
1881 2055 // Save the quote
1882 2056 $saved = $quote->save();
@@ -1881,9 +2055,9 @@
1881 2055 // Save the quote
1882 2056 $saved = $quote->save();
1883 2057
1884 2058 if (!$saved) {
1885 - wp_die(__('Failed to decline quote.', 'easy-invoice'));
2059 + wp_die(esc_html__('Failed to decline quote.', 'easy-invoice'));
1886 2060 }
1887 2061
1888 2062 // Send notification email to admin
1889 2063 if (!$is_admin) {
@@ -1891,9 +2065,9 @@
1891 2065 }
1892 2066
1893 2067 // Redirect back to the quote page with success message
1894 2068 $redirect_url = add_query_arg('action', 'declined', get_permalink($quote_id));
1895 - wp_redirect($redirect_url);
2069 + wp_safe_redirect($redirect_url);
1896 2070 exit;
1897 2071 }
1898 2072
1899 2073 /**
@@ -1997,19 +2171,23 @@
1997 2171 }
1998 2172
1999 2173 if ($error_count > 0) {
2000 2174 wp_send_json_success([
2001 - 'message' => sprintf(__('Processed %d quotes successfully. %d failed.', 'easy-invoice'), $success_count, $error_count),
2175 + /* translators: %1$d: number processed; %2$d: number failed. */
2176 + 'message' => sprintf(__('Processed %1$d quotes successfully. %2$d failed.', 'easy-invoice'), $success_count, $error_count),
2002 2177 'toast' => [
2003 2178 'type' => 'warning',
2004 - 'message' => sprintf(__('Processed %d quotes successfully. %d failed.', 'easy-invoice'), $success_count, $error_count)
2179 + /* translators: %1$d: number processed; %2$d: number failed. */
2180 + 'message' => sprintf(__('Processed %1$d quotes successfully. %2$d failed.', 'easy-invoice'), $success_count, $error_count)
2005 2181 ]
2006 2182 ]);
2007 2183 } else {
2008 2184 wp_send_json_success([
2185 + /* translators: %d: number processed. */
2009 2186 'message' => sprintf(__('Successfully processed %d quotes.', 'easy-invoice'), $success_count),
2010 2187 'toast' => [
2011 2188 'type' => 'success',
2189 + /* translators: %d: number processed. */
2012 2190 'message' => sprintf(__('Successfully processed %d quotes.', 'easy-invoice'), $success_count)
2013 2191 ]
2014 2192 ]);
2015 2193 }
@@ -2203,23 +2381,27 @@
2203 2381 }
2204 2382
2205 2383 if ($error_count > 0) {
2206 2384 wp_send_json_success([
2207 - 'message' => sprintf(__('Emptied trash: %d quotes deleted successfully, %d failed.', 'easy-invoice'), $success_count, $error_count),
2385 + /* translators: %1$d: number processed; %2$d: number failed. */
2386 + 'message' => sprintf(__('Emptied trash: %1$d quotes deleted successfully, %2$d failed.', 'easy-invoice'), $success_count, $error_count),
2208 2387 'success_count' => $success_count,
2209 2388 'error_count' => $error_count,
2210 2389 'toast' => [
2211 2390 'type' => 'warning',
2212 - 'message' => sprintf(__('Emptied trash: %d quotes deleted successfully, %d failed.', 'easy-invoice'), $success_count, $error_count)
2391 + /* translators: %1$d: number processed; %2$d: number failed. */
2392 + 'message' => sprintf(__('Emptied trash: %1$d quotes deleted successfully, %2$d failed.', 'easy-invoice'), $success_count, $error_count)
2213 2393 ]
2214 2394 ]);
2215 2395 } else {
2216 2396 wp_send_json_success([
2397 + /* translators: %d: number processed. */
2217 2398 'message' => sprintf(__('Successfully emptied trash: %d quotes deleted.', 'easy-invoice'), $success_count),
2218 2399 'success_count' => $success_count,
2219 2400 'error_count' => 0,
2220 2401 'toast' => [
2221 2402 'type' => 'success',
2403 + /* translators: %d: number processed. */
2222 2404 'message' => sprintf(__('Successfully emptied trash: %d quotes deleted.', 'easy-invoice'), $success_count)
2223 2405 ]
2224 2406 ]);
2225 2407 }