verifyNonce('easy_invoice_nonce'); if (!easy_invoice_user_can('ei_create_invoice')) { $this->sendError(__('You do not have permission to perform this action', 'easy-invoice')); } // Lifecycle-stage edit gate. Addons (PartialPayments) can return // false here to block edits to invoices whose state shouldn't // change anymore — e.g. a deposit invoice that has already been // paid (where editing items would silently invalidate the // deposit/balance pair the customer already saw). $editing_invoice_id = isset($_POST['invoice_id']) ? (int) $_POST['invoice_id'] : 0; if ($editing_invoice_id > 0 && !apply_filters('easy_invoice_can_edit_invoice', true, $editing_invoice_id)) { $this->sendError(__('This deposit invoice has already been paid and is locked from further edits. Add the new line item to the linked balance invoice instead.', 'easy-invoice')); } // Get the raw invoice data from the form $raw_invoice_data = isset($_POST['invoice_data']) ? $_POST['invoice_data'] : $_POST; // Remove non-invoice fields unset($raw_invoice_data['action']); unset($raw_invoice_data['nonce']); // Process the invoice data $invoice_form_manager = new \EasyInvoice\Forms\Invoice\InvoiceFormManager(); $invoice_data = $invoice_form_manager->processFormData($raw_invoice_data); if (!empty($invoice_data['errors'])) { // The toast is what the user sees; the field may sit on another tab. wp_send_json_error([ 'message' => implode(' ', array_map('strval', $invoice_data['errors'])), 'errors' => $invoice_data['errors'] ]); } // Handle items separately - process the natural form submission format if (isset($raw_invoice_data['items']) && is_array($raw_invoice_data['items'])) { // Form submits items as items[0][title], items[0][description], etc. // Convert to array of item objects for processing $items_array = []; foreach ($raw_invoice_data['items'] as $index => $item_data) { if (is_array($item_data)) { $items_array[] = $item_data; } } // Process items using the dynamic field system $invoice_data['data']['items'] = $invoice_form_manager->processItemsData($items_array); } // Handle special fields that might not be in the form definition if (isset($raw_invoice_data['invoice_id'])) { $invoice_data['data']['invoice_id'] = intval($raw_invoice_data['invoice_id']); } if (isset($raw_invoice_data['client_id'])) { $invoice_data['data']['client_id'] = intval($raw_invoice_data['client_id']); } $invoice_id = isset($invoice_data['data']['invoice_id']) ? intval($invoice_data['data']['invoice_id']) : 0; $repository = InvoiceServiceProvider::getInvoiceRepository(); if ($invoice_id > 0) { // Update existing invoice - preserve existing invoice number unset($invoice_data['data']['invoice_number']); unset($invoice_data['data']['number']); $invoice = $repository->update($invoice_id, $invoice_data['data']); if (!$invoice) { $this->sendError(__('Failed to update invoice', 'easy-invoice')); } // Use FormProcessor to save form data to database $form_processor = new \EasyInvoice\Forms\FormProcessor(); $all_fields = $invoice_form_manager->getAllFields(); $form_processor->saveFormDataToDatabase($invoice_data['data'], $all_fields, $invoice); $message = __('Invoice updated successfully', 'easy-invoice'); } else { // Create new invoice - allow auto-generated invoice number to be saved // The invoice number will be auto-generated by the form and included in the data $invoice = $repository->create($invoice_data['data']); if (!$invoice) { $this->sendError(__('Failed to create invoice', 'easy-invoice')); } // The repository claimed a number under its lock (or generated the next // one when the number the builder peeked on page load was taken // meanwhile). Carry that claimed number into the form data: the // FormProcessor below writes every posted field, and the stale peek // would otherwise overwrite the claim — two builders open at once // then saved two documents with the same number. $invoice_data['data']['number'] = $invoice->getNumber(); unset($invoice_data['data']['invoice_number']); // Use FormProcessor to save form data to database $form_processor = new \EasyInvoice\Forms\FormProcessor(); $all_fields = $invoice_form_manager->getAllFields(); $form_processor->saveFormDataToDatabase($invoice_data['data'], $all_fields, $invoice); $invoice_id = $invoice->getId(); $message = __('Invoice created successfully', 'easy-invoice'); } // Handle items if (isset($invoice_data['data']['items']) && is_array($invoice_data['data']['items'])) { $invoice->setItems($invoice_data['data']['items']); } $invoice_template = get_post_meta($invoice_id, '_easy_invoice_invoice_template', true); $invoice_template = $invoice_template=='' ? 'standard': $invoice_template; update_option('easy_invoice_last_invoice_template',$invoice_template ); // Prepare response data $response_data = array( 'invoice_id' => $invoice_id, 'invoice' => $invoice->toArray(), 'toast' => array( 'type' => 'success', 'message' => $message, 'options' => array('duration' => 4000) ) ); // Include client data if invoice has a client if ($invoice->getClientId()) { $client_repository = ClientServiceProvider::getClientRepository(); $client = $client_repository->find($invoice->getClientId()); if ($client) { $response_data['client'] = array( 'id' => $client->getId(), 'name' => $client->getBusinessClientName() ?: ($client->getFirstName() . ' ' . $client->getLastName()), 'email' => $client->getEmail() ?: '', 'phone' => $client->getExtraInfo() ?: '', 'company' => $client->getBusinessClientName() ?: '', 'address' => $client->getAddress() ?: '', 'website' => $client->getWebsite() ?: '', ); } } wp_send_json_success($response_data); } /** * Save and send invoice */ public function saveAndSendInvoice() { $this->verifyNonce('easy_invoice_nonce'); // Compound action: needs both create-edit and send rights. if (!easy_invoice_user_can('ei_create_invoice') || !easy_invoice_user_can('ei_send_invoice')) { $this->sendError(__('You do not have permission to perform this action', 'easy-invoice')); } // First save the invoice $this->saveInvoice(); // If we get here, the invoice was saved successfully // Now send the invoice via email $invoice_id = isset($_POST['invoice_data']['invoice_id']) ? intval($_POST['invoice_data']['invoice_id']) : 0; if ($invoice_id > 0) { // Send the invoice via email $result = $this->sendInvoiceEmail($invoice_id); if ($result['success']) { $this->sendSuccess(array( 'message' => __('Invoice saved and sent successfully', 'easy-invoice'), 'invoice_id' => $invoice_id )); } else { $this->sendError($result['message']); } } else { $this->sendError(__('Invalid invoice ID for sending', 'easy-invoice')); } } /** * Delete invoice */ public function deleteInvoice() { $this->verifyNonce('easy_invoice_nonce'); if (!easy_invoice_user_can('ei_delete_invoice')) { $this->sendError(__('You do not have permission to perform this action', 'easy-invoice')); } $invoice_id = isset($_POST['invoice_id']) ? intval($_POST['invoice_id']) : 0; if ($invoice_id <= 0) { $this->sendError(__('Invalid invoice ID', 'easy-invoice')); } $repository = InvoiceServiceProvider::getInvoiceRepository(); $result = $repository->delete($invoice_id); if (!$result) { $this->sendError(__('Failed to delete invoice', 'easy-invoice')); } $this->sendSuccess(array( 'message' => __('Invoice deleted successfully', 'easy-invoice'), 'invoice_id' => $invoice_id, )); } /** * Get invoice */ public function getInvoice() { $this->verifyNonce('easy_invoice_nonce'); if (!easy_invoice_user_can('ei_view_invoices')) { $this->sendError(__('You do not have permission to perform this action', 'easy-invoice')); } $invoice_id = isset($_REQUEST['invoice_id']) ? intval($_REQUEST['invoice_id']) : 0; if ($invoice_id <= 0) { $this->sendError(__('Invalid invoice ID', 'easy-invoice')); } $repository = InvoiceServiceProvider::getInvoiceRepository(); $invoice = $repository->find($invoice_id); if (!$invoice) { $this->sendError(__('Invoice not found', 'easy-invoice')); } $this->sendSuccess(array( 'invoice' => $invoice->toArray(), )); } /** * Save client */ public function saveClient() { $this->verifyNonce('easy_invoice_nonce'); if (!easy_invoice_user_can('ei_manage_clients')) { $this->sendError(__('You do not have permission to perform this action', 'easy-invoice')); } $client_id = isset($_POST['client_id']) ? intval($_POST['client_id']) : 0; $client_data = isset($_POST['client_data']) ? $this->sanitizeData($_POST['client_data']) : array(); if (empty($client_data)) { $this->sendError(__('Invalid client data', 'easy-invoice')); } $repository = ClientServiceProvider::getClientRepository(); if ($client_id > 0) { // Update existing client $client = $repository->update($client_id, $client_data); if (!$client) { $this->sendError(__('Failed to update client', 'easy-invoice')); } $message = __('Client updated successfully', 'easy-invoice'); } else { // Create new client $client = $repository->create($client_data); if (!$client) { $this->sendError(__('Failed to create client', 'easy-invoice')); } $client_id = $client->getId(); $message = __('Client created successfully', 'easy-invoice'); } $this->sendSuccess(array( 'message' => $message, 'client_id' => $client_id, 'client' => $client->toArray(), )); } /** * Delete client */ public function deleteClient() { try { $this->verifyNonce('easy_invoice_nonce'); if (!easy_invoice_user_can('ei_manage_clients')) { $this->sendError(__('You do not have permission to perform this action', 'easy-invoice')); } $client_id = isset($_POST['client_id']) ? intval($_POST['client_id']) : 0; // The dialog posts the literal strings "true" / "false"; a bool cast // made "false" true, so "Delete client only" removed the documents too. $delete_associated_documents = isset($_POST['delete_associated_documents']) && filter_var(wp_unslash($_POST['delete_associated_documents']), FILTER_VALIDATE_BOOLEAN); if ($client_id <= 0) { $this->sendError(__('Invalid client ID', 'easy-invoice')); } // Check if the user exists and is not an administrator $user = get_user_by('ID', $client_id); if (!$user) { $this->sendError(__('User not found', 'easy-invoice')); } if (in_array('administrator', $user->roles)) { $this->sendError(__('Cannot delete administrator accounts', 'easy-invoice')); } global $wpdb; // Get counts of associated documents $invoice_count = $wpdb->get_var($wpdb->prepare( "SELECT COUNT(*) FROM {$wpdb->postmeta} WHERE meta_key = '_easy_invoice_client_id' AND meta_value = %d", $client_id )); $quote_count = $wpdb->get_var($wpdb->prepare( "SELECT COUNT(*) FROM {$wpdb->postmeta} WHERE meta_key = '_easy_invoice_quote_client_id' AND meta_value = %d", $client_id )); // Counted through the client's invoices, because that is the only // link there is: payments record '_invoice_id' and no client id. // This counted meta '_easy_payment_client_id', which nothing writes, // so the confirmation dialog told every user there were no payments // no matter how many there were. $payment_count = count(\EasyInvoice\Services\ClientLedger::paymentIds((int) $client_id)); $total_documents = $invoice_count + $quote_count + $payment_count; if ($delete_associated_documents) { // Delete all associated documents // error_log(), not $this->log(): no such method exists on this class or // any trait it uses, so both branches of this handler raised // "Call to undefined method" — deleting a client failed with a critical // error whichever option the administrator chose. Matches the logging // used elsewhere in the plugin. error_log(sprintf('Easy Invoice: deleting client %d with all associated documents (%d invoices, %d quotes, %d payments)', $client_id, $invoice_count, $quote_count, $payment_count)); // Delete invoices if ($invoice_count > 0) { $invoices = $wpdb->get_col($wpdb->prepare( "SELECT post_id FROM {$wpdb->postmeta} WHERE meta_key = '_easy_invoice_client_id' AND meta_value = %d", $client_id )); foreach ($invoices as $invoice_id) { wp_delete_post($invoice_id, true); } } // Delete quotes if ($quote_count > 0) { $quotes = $wpdb->get_col($wpdb->prepare( "SELECT post_id FROM {$wpdb->postmeta} WHERE meta_key = '_easy_invoice_quote_client_id' AND meta_value = %d", $client_id )); foreach ($quotes as $quote_id) { wp_delete_post($quote_id, true); } } // Delete payments // // This queried '_easy_invoice_payment_client_id' while the matching // count above (and the meta cleanup below, and // ClientRepository::countClientPayments) all query // '_easy_payment_client_id' — so the count and the deletion disagreed // on which key identifies a client's payments. Unified on // '_easy_payment_client_id', the key the other three sites use. // // Payments are RETAINED, on purpose, and the message below says so. // // Neither of those meta keys is ever written: a payment stores // '_invoice_id' and carries no client id at all, so it is linked to // a client only through its invoice. Client deletion has therefore // never removed a payment, whatever the dialog implied. // // $payment_count is now resolved correctly through ClientLedger, so // the count is true even though the behaviour is unchanged. Making // the deletion true as well would destroy records this path has // never touched — a payment is the evidence money changed hands, and // the reasoning that stops InvoiceRetention deleting an issued // invoice applies to it. That is a deliberate decision to keep them, // not an oversight, so it is stated to the user rather than hidden. // Say what was kept as well as what went. "All associated documents // deleted" was never true where payments were concerned, and a // merchant who believes their payment records are gone will look // for them in the wrong place at the wrong time of year. $message = $payment_count > 0 ? sprintf( /* translators: 1: number of documents deleted, 2: number of payment records kept. */ _n( 'Client deleted, along with %1$d document. %2$d payment record was kept as a financial record.', 'Client deleted, along with %1$d documents. %2$d payment records were kept as financial records.', $payment_count, 'easy-invoice' ), $invoice_count + $quote_count, $payment_count ) : sprintf( /* translators: %d: number of documents deleted. */ __('Client and all associated documents (%d total) deleted successfully', 'easy-invoice'), $total_documents ); } else { // Only remove client associations, preserve documents // See the note on the other branch above. error_log(sprintf('Easy Invoice: removing client associations for client %d (%d invoices, %d quotes, %d payments)', $client_id, $invoice_count, $quote_count, $payment_count)); // Remove client associations from invoices if ($invoice_count > 0) { $wpdb->delete( $wpdb->postmeta, ['meta_key' => '_easy_invoice_client_id', 'meta_value' => $client_id] ); } // Remove client associations from quotes if ($quote_count > 0) { $wpdb->delete( $wpdb->postmeta, ['meta_key' => '_easy_invoice_quote_client_id', 'meta_value' => $client_id] ); } // Remove client associations from payments if ($payment_count > 0) { $wpdb->delete( $wpdb->postmeta, ['meta_key' => '_easy_payment_client_id', 'meta_value' => $client_id] ); } /* translators: %d: number of documents. */ $message = sprintf(__('Client deleted successfully. %d documents preserved but client associations removed.', 'easy-invoice'), $total_documents); } // Snapshot identity BEFORE delete — once wp_delete_user runs the // user record is gone and we can't backfill the audit context. $deleted_login = $user && $user->user_login ? $user->user_login : ''; $deleted_email = $user && $user->user_email ? $user->user_email : ''; // Delete the WordPress user require_once(ABSPATH . 'wp-admin/includes/user.php'); $result = wp_delete_user($client_id); if (!$result) { $this->sendError(__('Failed to delete client', 'easy-invoice')); } // Audit: record the delete with enough context to investigate later. if (function_exists('easy_invoice_audit_log')) { easy_invoice_audit_log('client_deleted', 'client', $client_id, [ 'login' => $deleted_login, 'email' => $deleted_email, 'invoices_affected' => (int) $invoice_count, 'quotes_affected' => (int) $quote_count, 'payments_affected' => (int) $payment_count, 'cascade_delete' => $delete_associated_documents, ]); } $this->sendSuccess(array( 'message' => $message, 'client_id' => $client_id, 'documents_deleted' => $delete_associated_documents, 'total_documents' => $total_documents )); } catch (\Exception $e) { $this->sendError($e->getMessage()); } } /** * Get client */ public function getClient() { $this->verifyNonce('easy_invoice_nonce'); if (!easy_invoice_user_can('ei_view_clients')) { $this->sendError(__('You do not have permission to perform this action', 'easy-invoice')); } $client_id = isset($_REQUEST['client_id']) ? intval($_REQUEST['client_id']) : 0; if ($client_id <= 0) { $this->sendError(__('Invalid client ID', 'easy-invoice')); } $repository = ClientServiceProvider::getClientRepository(); $client = $repository->find($client_id); if (!$client) { $this->sendError(__('Client not found', 'easy-invoice')); } $client_data = $client->toArray(); // Return comprehensive client data in a unified format that works for both form population and display $this->sendSuccess(array( // Form population fields (for invoice-builder.js and invoice-form.js) 'name' => $client_data['company_name'] ?? $client_data['contact_name'] ?? '', 'email' => $client_data['email'] ?? '', 'phone' => $client_data['phone'] ?? '', 'company' => $client_data['company_name'] ?? '', 'address' => $client_data['billing_address'] ?? '', 'website' => $client_data['website'] ?? '', // Display fields (for client-manager.js) 'business_client_name' => $client->getBusinessClientName(), 'username' => $client->getUsername(), 'extra_info' => $client->getExtraInfo(), 'first_name' => $client->getFirstName(), 'last_name' => $client->getLastName(), // Raw data for backward compatibility 'client' => array( 'name' => $client_data['company_name'] ?? $client_data['contact_name'] ?? '', 'email' => $client_data['email'] ?? '', 'phone' => $client_data['phone'] ?? '', 'company' => $client_data['company_name'] ?? '', 'address' => $client_data['billing_address'] ?? '', 'website' => $client_data['website'] ?? '', ) )); } /** * Verify nonce * * @param string $action The nonce action */ private function verifyNonce($action) { // Check for _nonce (standard format) first if (isset($_REQUEST['_nonce']) && wp_verify_nonce($_REQUEST['_nonce'], $action)) { return; } // Also check for 'nonce' (client form format) if (isset($_REQUEST['nonce']) && wp_verify_nonce($_REQUEST['nonce'], $action)) { return; } // If we get here, neither nonce format was valid $this->sendError(__('Security check failed', 'easy-invoice')); } /** * Sanitize data * * @param array $data The data to sanitize * @return array The sanitized data */ private function sanitizeData($data) { if (!is_array($data)) { return array(); } $sanitized = array(); // Define fields that should allow HTML (like textarea content) $html_fields = [ 'invoice_description', 'description', 'notes', 'terms', 'internal_notes', 'customer_address' ]; // Define numeric fields $numeric_fields = [ 'invoice_id', 'client_id', 'discount_value', 'tax_rate' ]; foreach ($data as $key => $value) { if (is_array($value)) { $sanitized[$key] = $this->sanitizeData($value); } else if (in_array($key, $html_fields)) { // For HTML fields, use wp_kses to allow certain tags but prevent XSS $sanitized[$key] = wp_kses_post($value); } else if (in_array($key, $numeric_fields)) { // For numeric fields, ensure they're valid numbers $sanitized[$key] = is_numeric($value) ? $value : 0; } else { $sanitized[$key] = sanitize_text_field($value); } } return $sanitized; } /** * Send success response */ private function sendSuccess($data = array()) { // Check if we should suppress global toast $suppress_toast = isset($_POST['suppress_global_toast']) && $_POST['suppress_global_toast'] === 'true'; // Only inject the toast key when $data is an associative array // (or empty). If $data is a numeric-indexed list (e.g. search // results), adding a string key would mutate the array shape: // PHP keeps the mixed keys, but `wp_send_json_success` then // serialises the value as a JSON OBJECT instead of an array, // breaking any frontend that does `response.data.length` or // `response.data.forEach(...)` — the exact bug that caused the // client-search dropdown to silently render empty results. $is_assoc_or_empty = !is_array($data) || empty($data) || array_keys($data) !== range(0, count($data) - 1); if ($is_assoc_or_empty && !isset($data['toast']) && !$suppress_toast) { $message = isset($data['message']) ? $data['message'] : __('Operation completed successfully', 'easy-invoice'); $data['toast'] = array( 'type' => 'success', 'message' => $message, 'options' => array('duration' => 4000) ); } // Remove toast data if suppressed if ($is_assoc_or_empty && $suppress_toast && isset($data['toast'])) { unset($data['toast']); } wp_send_json_success($data); } /** * Send error response */ private function sendError($message, $data = array()) { // Add toast notification $data['toast'] = array( 'type' => 'error', 'message' => $message, 'options' => array('duration' => 6000) ); wp_send_json_error($data); } /** * Download invoice as PDF */ public function downloadPdf() { // Verify nonce $this->verifyNonce('easy_invoice_nonce'); // Check if user has required capability if (!easy_invoice_user_can('ei_view_invoices')) { $this->sendError(__('You do not have permission to download invoices', 'easy-invoice')); } // Get invoice ID $invoice_id = isset($_REQUEST['invoice_id']) ? intval($_REQUEST['invoice_id']) : 0; if (!$invoice_id) { $this->sendError(__('Invalid invoice ID', 'easy-invoice')); } // Get invoice from repository $repository = InvoiceServiceProvider::getInvoiceRepository(); $invoice = $repository->find($invoice_id); if (!$invoice) { $this->sendError(__('Invoice not found', 'easy-invoice')); } // Get invoice data for PDF generation $invoice_data = \EasyInvoice\Includes\Helpers\PdfHelper::getInvoiceDataForPdf($invoice); // Return success response with invoice data $this->sendSuccess(array( 'message' => __('Invoice data retrieved successfully', 'easy-invoice'), 'invoice_data' => $invoice_data )); } /** * Send invoice via email */ public function sendInvoiceEmail() { $this->verifyNonce('easy_invoice_nonce'); if (!easy_invoice_user_can('ei_send_invoice')) { $this->sendError(__('You do not have permission to perform this action', 'easy-invoice')); } // Get invoice ID from POST data $invoice_id = isset($_POST['invoice_id']) ? intval($_POST['invoice_id']) : 0; if (!$invoice_id) { $this->sendError(__('Invalid invoice ID', 'easy-invoice')); } $repository = InvoiceServiceProvider::getInvoiceRepository(); $invoice = $repository->find($invoice_id); if (!$invoice) { $this->sendError(__('Invoice not found', 'easy-invoice')); } // Use EmailManager to send the email $email_manager = \EasyInvoice\Services\EmailManager::getInstance(); $result = $email_manager->sendInvoiceEmail($invoice, 'new'); if ($result['success']) { // Audit: who sent which invoice to which client, at what time. if (function_exists('easy_invoice_audit_log')) { easy_invoice_audit_log('invoice_sent', 'invoice', $invoice_id, [ 'recipient' => is_callable([$invoice, 'getCustomerEmail']) ? $invoice->getCustomerEmail() : '', 'context' => 'new', ]); } $this->sendSuccess(array( 'message' => $result['message'] )); } else { $this->sendError($result['message']); } } /** * Download quote as PDF */ public function downloadQuotePdf() { // Verify nonce $this->verifyNonce('easy_invoice_nonce'); // Check if user has required capability if (!easy_invoice_user_can('ei_view_quotes')) { $this->sendError(__('You do not have permission to download quotes', 'easy-invoice')); } // Get quote ID $quote_id = isset($_POST['quote_id']) ? intval($_POST['quote_id']) : 0; if (!$quote_id) { $this->sendError(__('Invalid quote ID', 'easy-invoice')); } // Get quote from repository $repository = \EasyInvoice\Providers\QuoteServiceProvider::getQuoteRepository(); $quote = $repository->find($quote_id); if (!$quote) { $this->sendError(__('Quote not found', 'easy-invoice')); } // For now, return success response with quote data // PDF generation can be implemented later with actual PDF creation $this->sendSuccess(array( 'message' => __('Quote data retrieved successfully', 'easy-invoice'), 'quote_data' => $quote->toArray(), 'download_url' => add_query_arg(array( 'action' => 'easy_invoice_generate_quote_pdf', 'quote_id' => $quote_id, // Bound to this quote — see the invoice equivalent above. 'nonce' => wp_create_nonce('generate_quote_pdf_' . $quote_id) ), admin_url('admin-ajax.php')) )); } /** * Save quote */ public function saveQuote() { $this->verifyNonce('easy_invoice_nonce'); if (!easy_invoice_user_can('ei_create_quote')) { $this->sendError(__('You do not have permission to perform this action', 'easy-invoice')); } // Get the raw quote data from the form $raw_quote_data = isset($_POST['quote_data']) ? $_POST['quote_data'] : $_POST; // Remove non-quote fields unset($raw_quote_data['action']); unset($raw_quote_data['nonce']); // Process the quote data $quote_form_manager = new \EasyInvoice\Forms\Quote\QuoteFormManager(); $quote_data = $quote_form_manager->processFormData($raw_quote_data); if (!empty($quote_data['errors'])) { wp_send_json_error([ 'message' => implode(' ', array_map('strval', $quote_data['errors'])), 'errors' => $quote_data['errors'] ]); } // Handle items separately - process the natural form submission format if (isset($raw_quote_data['items']) && is_array($raw_quote_data['items'])) { // Form submits items as items[0][title], items[0][description], etc. // Convert to array of item objects for processing $items_array = []; foreach ($raw_quote_data['items'] as $index => $item_data) { if (is_array($item_data)) { $items_array[] = $item_data; } } // Process items using the dynamic field system $quote_data['data']['items'] = $quote_form_manager->processItemsData($items_array); } // Handle special fields that might not be in the form definition if (isset($raw_quote_data['quote_id'])) { $quote_data['data']['quote_id'] = intval($raw_quote_data['quote_id']); } if (isset($raw_quote_data['client_id'])) { $quote_data['data']['client_id'] = intval($raw_quote_data['client_id']); } $quote_id = isset($quote_data['data']['quote_id']) ? intval($quote_data['data']['quote_id']) : 0; $repository = \EasyInvoice\Providers\QuoteServiceProvider::getQuoteRepository(); if ($quote_id > 0) { // Update existing quote - preserve existing quote number unset($quote_data['data']['quote_number']); unset($quote_data['data']['number']); // Get the existing quote first $quote = $repository->find($quote_id); if (!$quote) { $this->sendError(__('Failed to find quote for update', 'easy-invoice')); } // Use FormProcessor to save form data to database BEFORE repository update $form_processor = new \EasyInvoice\Forms\FormProcessor(); $all_fields = $quote_form_manager->getAllFields(); $form_processor->saveFormDataToDatabase($quote_data['data'], $all_fields, $quote); // Now update the quote with the processed data, passing the existing quote object $quote = $repository->update($quote_id, $quote_data['data'], $quote); if (!$quote) { $this->sendError(__('Failed to update quote', 'easy-invoice')); } $message = __('Quote updated successfully', 'easy-invoice'); } else { // Create new quote - allow auto-generated quote number to be saved // The quote number will be auto-generated by the form and included in the data $quote = $repository->create($quote_data['data']); if (!$quote) { $this->sendError(__('Failed to create quote', 'easy-invoice')); } // Same as invoices: keep the number the repository claimed, not the one // the builder peeked on page load (see saveInvoice()). $quote_data['data']['number'] = $quote->getNumber(); unset($quote_data['data']['quote_number']); // Use FormProcessor to save form data to database $form_processor = new \EasyInvoice\Forms\FormProcessor(); $all_fields = $quote_form_manager->getAllFields(); $form_processor->saveFormDataToDatabase($quote_data['data'], $all_fields, $quote); $quote_id = $quote->getId(); $message = __('Quote created successfully', 'easy-invoice'); } // Handle items if (isset($quote_data['data']['items']) && is_array($quote_data['data']['items'])) { $quote->setItems($quote_data['data']['items']); // Save the quote to persist the items to database $quote->save(); } $quote_template = get_post_meta($quote_id, '_easy_invoice_quote_quote_template', true); $quote_template = $quote_template=='' ? 'standard': $quote_template; update_option('easy_invoice_last_quote_template',$quote_template ); // Prepare response data $response_data = array( 'quote_id' => $quote_id, 'quote' => $quote->toArray(), 'toast' => array( 'type' => 'success', 'message' => $message, 'options' => array('duration' => 4000) ) ); // Include client data if quote has a client if ($quote->getClientId()) { $client_repository = ClientServiceProvider::getClientRepository(); $client = $client_repository->find($quote->getClientId()); if ($client) { $response_data['client'] = array( 'id' => $client->getId(), 'name' => $client->getBusinessClientName() ?: ($client->getFirstName() . ' ' . $client->getLastName()), 'email' => $client->getEmail() ?: '', 'phone' => $client->getExtraInfo() ?: '', 'company' => $client->getBusinessClientName() ?: '', 'address' => $client->getAddress() ?: '', 'website' => $client->getWebsite() ?: '', ); } } $this->sendSuccess($response_data); } /** * Toggle a template as favorite */ /** * Check if an email already exists for any client */ public function checkEmailExists() { $this->verifyNonce('easy_invoice_nonce'); // Email-lookup is used during client creation; anyone who can manage // clients can check duplicates. if (!easy_invoice_user_can('ei_manage_clients')) { $this->sendError(__('You do not have permission to perform this action', 'easy-invoice')); } $email = isset($_POST['email']) ? sanitize_email($_POST['email']) : ''; if (empty($email)) { $this->sendSuccess(array('exists' => false)); } $repository = ClientServiceProvider::getClientRepository(); $existing_clients = $repository->findByEmail($email); $this->sendSuccess(array( 'exists' => !empty($existing_clients), 'count' => count($existing_clients) )); } /** * Generate a secure password. */ public function generatePassword() { $this->verifyNonce('easy_invoice_nonce'); // Used when creating a client (WP user); same gate as client management. if (!easy_invoice_user_can('ei_manage_clients')) { $this->sendError(__('You do not have permission to perform this action', 'easy-invoice')); } $password = wp_generate_password(16, true, true); // Check if we should suppress global toast $suppress_toast = isset($_POST['suppress_global_toast']) && $_POST['suppress_global_toast'] === 'true'; $this->sendSuccess(array( 'password' => $password, 'suppress_toast' => $suppress_toast )); } /** * Sanitize invoice items * * @param array $items Raw items data * @return array Sanitized items data */ private function sanitizeItems(array $items): array { $sanitized_items = []; // Get field configuration for dynamic processing $form_manager = new \EasyInvoice\Forms\Invoice\InvoiceFormManager(); $field_config = $form_manager->getItemFields(); foreach ($items as $item) { if (!is_array($item)) { continue; } $sanitized_item = []; // Process each field dynamically based on configuration foreach ($field_config as $field) { $field_name = $field['name'] ?? ''; $field_type = $field['type'] ?? 'text'; $raw_value = $item[$field_name] ?? ''; // Apply field-specific sanitization switch ($field_type) { case 'text': $sanitized_item[$field_name] = sanitize_text_field($raw_value); break; case 'textarea': $sanitized_item[$field_name] = wp_kses_post($raw_value); break; case 'number': $sanitized_item[$field_name] = is_numeric($raw_value) ? floatval($raw_value) : 0; break; case 'checkbox': $sanitized_item[$field_name] = !empty($raw_value) ? true : false; break; default: $sanitized_item[$field_name] = sanitize_text_field($raw_value); break; } } // Handle legacy field names for backward compatibility if (isset($item['name']) && !isset($sanitized_item['title'])) { $sanitized_item['title'] = sanitize_text_field($item['name']); } if (isset($item['title']) && !isset($sanitized_item['title'])) { $sanitized_item['title'] = sanitize_text_field($item['title']); } // Only add items that have at least a title/name if (!empty($sanitized_item['title'])) { $sanitized_items[] = $sanitized_item; } } return $sanitized_items; } /** * Add a new client (specifically for the client form in templates/clients-page.php) */ public function addClient() { $this->verifyNonce('easy_invoice_nonce'); if (!easy_invoice_user_can('ei_manage_clients')) { $this->sendError(__('You do not have permission to perform this action', 'easy-invoice')); } // Check if required fields are present $required_fields = ['business_client_name', 'email', 'username']; foreach ($required_fields as $field) { if (!isset($_POST[$field]) || empty($_POST[$field])) { /* translators: %s: form field name. */ $this->sendError(sprintf(__('Missing required field: %s', 'easy-invoice'), $field)); } } // Prepare client data // Optional fields may be absent from the request entirely. $post_text = static function ($key) { return isset($_POST[$key]) ? sanitize_text_field(wp_unslash($_POST[$key])) : ''; }; // phpcs:ignore WordPress.Security.NonceVerification.Missing -- checked above. $post_area = static function ($key) { return isset($_POST[$key]) ? sanitize_textarea_field(wp_unslash($_POST[$key])) : ''; }; // phpcs:ignore WordPress.Security.NonceVerification.Missing $client_data = [ ClientFields::BUSINESS_CLIENT_NAME => $post_text('business_client_name'), ClientFields::EMAIL => sanitize_email(wp_unslash(($_POST['email'] ?? ''))), ClientFields::USERNAME => sanitize_user(wp_unslash(($_POST['username'] ?? ''))), ClientFields::PASSWORD => isset($_POST['password']) ? (string) wp_unslash($_POST['password']) : '', ClientFields::ADDRESS => $post_area('address'), ClientFields::EXTRA_INFO => $post_area('extra_info'), ClientFields::FIRST_NAME => $post_text('first_name'), ClientFields::LAST_NAME => $post_text('last_name'), ClientFields::WEBSITE => isset($_POST['website']) ? esc_url_raw(wp_unslash($_POST['website'])) : '', ClientFields::PHONE => isset($_POST['phone']) ? sanitize_text_field($_POST['phone']) : '', ]; // Basic validation if (empty($client_data[ClientFields::BUSINESS_CLIENT_NAME]) && (empty($client_data[ClientFields::FIRST_NAME]) || empty($client_data[ClientFields::LAST_NAME]))) { $this->sendError(__('Please provide a client name or first/last name.', 'easy-invoice')); } if (empty($client_data[ClientFields::EMAIL])) { $this->sendError(__('Email address is required', 'easy-invoice')); } $repository = ClientServiceProvider::getClientRepository(); // Create new client $client = $repository->create($client_data); if (!$client) { $reason = method_exists($repository, 'getLastError') ? $repository->getLastError() : ''; $this->sendError($reason !== '' ? $reason : __('Failed to create client', 'easy-invoice')); } $client_id = $client->getId(); // Pull the WP role assigned during user creation. The // Clients-page row template needs this so the new-row badge // matches the role that will be re-rendered server-side on the // next page load. Without this, the JS template would have to // hardcode a role label and could drift from PHP's value. $user = get_user_by('id', $client_id); $role = ($user && !empty($user->roles)) ? (string) $user->roles[0] : 'customer'; $response_data = array( 'message' => __('Client added successfully', 'easy-invoice'), 'client_id' => $client_id, 'role' => $role, 'role_label' => ucfirst($role), 'client' => $client->toArray(), ); $this->sendSuccess($response_data); } /** * Update client from the client edit form */ public function updateClient() { $this->verifyNonce('easy_invoice_nonce'); if (!easy_invoice_user_can('ei_manage_clients')) { $this->sendError(__('You do not have permission to perform this action', 'easy-invoice')); } $client_id = isset($_POST['client_id']) ? intval($_POST['client_id']) : 0; if ($client_id <= 0) { $this->sendError(__('Invalid client ID', 'easy-invoice')); } // Prepare client data $client_data = [ ClientFields::BUSINESS_CLIENT_NAME => sanitize_text_field(($_POST['business_client_name'] ?? '')), ClientFields::EMAIL => sanitize_email(($_POST['email'] ?? '')), ClientFields::USERNAME => sanitize_user(($_POST['username'] ?? '')), ClientFields::PASSWORD => ($_POST['password'] ?? ''), // Keep password as is, don't sanitize ClientFields::ADDRESS => sanitize_textarea_field(($_POST['address'] ?? '')), ClientFields::PHONE => isset($_POST['phone']) ? sanitize_text_field($_POST['phone']) : '', ClientFields::EXTRA_INFO => sanitize_textarea_field(($_POST['extra_info'] ?? '')), ClientFields::FIRST_NAME => sanitize_text_field(($_POST['first_name'] ?? '')), ClientFields::LAST_NAME => sanitize_text_field(($_POST['last_name'] ?? '')), ClientFields::WEBSITE => esc_url_raw(($_POST['website'] ?? '')) ]; // Remove empty values except password (password can be empty for updates) $client_data = array_filter($client_data, function($value, $key) { if ($key === ClientFields::PASSWORD) { return true; // Always include password field } return $value !== ''; }, ARRAY_FILTER_USE_BOTH); if (empty($client_data)) { $this->sendError(__('No data provided to update.', 'easy-invoice')); } $repository = ClientServiceProvider::getClientRepository(); // Update existing client $client = $repository->update($client_id, $client_data); if (!$client) { $this->sendError(__('Failed to update client', 'easy-invoice')); } $this->sendSuccess(array( 'message' => __('Client updated successfully', 'easy-invoice'), 'client_id' => $client_id, 'client' => $client->toArray(), )); } /** * Update invoices data with missing client information and totals */ public function updateInvoicesData() { $this->verifyNonce('easy_invoice_admin_nonce'); // Bulk migration / repair of invoice records — admin-only. if (!current_user_can('manage_options')) { $this->sendError(__('You do not have permission to perform this action', 'easy-invoice')); } $repository = InvoiceServiceProvider::getInvoiceRepository(); $client_repository = ClientServiceProvider::getClientRepository(); // Get all invoices $invoices = $repository->all(); $updated_count = 0; foreach ($invoices as $invoice) { $updated = false; // Check if client data is missing $client_id = $invoice->getClientId(); if ($client_id > 0) { $client = $client_repository->find($client_id); if ($client) { // Update customer name if missing $customer_name = $invoice->getCustomerName(); if (empty($customer_name)) { $customer_name = $client->getBusinessClientName() ?: ($client->getFirstName() . ' ' . $client->getLastName()); $invoice->setCustomerName($customer_name); $updated = true; } // Update customer email if missing $customer_email = $invoice->getCustomerEmail(); if (empty($customer_email)) { $customer_email = $client->getEmail(); $invoice->setCustomerEmail($customer_email); $updated = true; } // Update customer address if missing $customer_address = $invoice->getCustomerAddress(); if (empty($customer_address)) { $customer_address = $client->getAddress(); $invoice->setCustomerAddress($customer_address); $updated = true; } } } // Check if total is missing or zero $total = $invoice->getTotal(); if (empty($total) || $total == 0) { // Recalculate total from items $items = $invoice->getItems(); if (!empty($items)) { $subtotal = 0; foreach ($items as $item) { if (method_exists($item, 'getAmount')) { $subtotal += $item->getAmount(); } elseif (isset($item['amount'])) { $subtotal += $item['amount']; } } // Calculate discount and tax $discount = $invoice->getDiscountAmount(); $tax = $invoice->getTaxAmount(); $total = $subtotal - $discount + $tax; // Save the calculated total $invoice->setMeta('_easy_invoice_total', $total); $updated = true; } } if ($updated) { $updated_count++; } } $this->sendSuccess(array( /* translators: %d: number updated. */ 'message' => sprintf(__('Updated %d invoices with missing data', 'easy-invoice'), $updated_count), 'updated_count' => $updated_count )); } /** * Download invoice as PDF (public access) */ public function downloadInvoicePdf() { // Verify nonce $this->verifyNonce('easy_invoice_nonce'); // Get invoice ID $invoice_id = isset($_POST['invoice_id']) ? intval($_POST['invoice_id']) : 0; if (!$invoice_id) { $this->sendError(__('Invalid invoice ID', 'easy-invoice')); } $repository = InvoiceServiceProvider::getInvoiceRepository(); $invoice = $repository->find($invoice_id); if (!$invoice) { $this->sendError(__('Invoice not found', 'easy-invoice')); } // Authorisation. // // This used to read: admins get find(), everyone else gets findPublished(). // That was not a check at all — Models\Invoice::save() writes every invoice // with post_status 'publish' regardless of its workflow status, so // findPublished() returned the same record find() would have, for anyone. // This endpoint is registered for wp_ajax_nopriv, so the effective gate was // the nonce alone and any caller holding one could pull the PDF data for an // arbitrary invoice id, including drafts. // // Uses the same helper as the rest of the plugin so there is a single // definition of who may see a document: valid ?ik= token, administrator, or // the logged-in client the invoice is bound to. if (!\EasyInvoice\Controllers\InvoiceController::canSubmitPaymentForInvoice($invoice_id, $invoice)) { $this->sendError(__('You do not have permission to access this invoice', 'easy-invoice')); } // Get invoice data for PDF generation $invoice_data = \EasyInvoice\Includes\Helpers\PdfHelper::getInvoiceDataForPdf($invoice); // For now, return success response with invoice data // PDF generation can be implemented later with actual PDF creation $this->sendSuccess(array( 'message' => __('Invoice data retrieved successfully', 'easy-invoice'), 'invoice_data' => $invoice_data, 'download_url' => add_query_arg(array( 'action' => 'easy_invoice_generate_pdf', 'invoice_id' => $invoice_id, // Bound to this invoice: an unscoped 'generate_pdf' nonce could be // taken from a document the caller may legitimately see and replayed // against any other invoice id. 'nonce' => wp_create_nonce('generate_pdf_' . $invoice_id) ), admin_url('admin-ajax.php')) )); } /** * Send invoice via email (public access) */ public function sendInvoiceEmailPublic() { $this->verifyNonce('easy_invoice_send_invoice_email'); // Get invoice ID $invoice_id = isset($_POST['invoice_id']) ? intval($_POST['invoice_id']) : 0; if (!$invoice_id) { $this->sendError(__('Invalid invoice ID', 'easy-invoice')); } // Get invoice from repository (only published invoices for public access) $repository = InvoiceServiceProvider::getInvoiceRepository(); $invoice = $repository->find($invoice_id); if (!$invoice) { $this->sendError(__('Invoice not found', 'easy-invoice')); } // Authorisation. The previous admin / findPublished() split was not a check: // every invoice is saved with post_status 'publish', so findPublished() // returned exactly what find() would, for any caller. This endpoint is // registered nopriv, so without this an unauthorised caller could make the // site email an arbitrary invoice out to its client. See downloadInvoicePdf(). if (!\EasyInvoice\Controllers\InvoiceController::canSubmitPaymentForInvoice($invoice_id, $invoice)) { $this->sendError(__('You do not have permission to access this invoice', 'easy-invoice')); } // Use EmailManager to send the email $email_manager = \EasyInvoice\Services\EmailManager::getInstance(); $result = $email_manager->sendInvoiceEmail($invoice, 'new'); if ($result['success']) { $this->sendSuccess(array( 'message' => $result['message'] )); } else { $this->sendError($result['message']); } } /** * Send quote via email (admin + public; guests only for published quotes). */ public function sendQuoteEmailPublic() { $this->verifyNonce('easy_invoice_send_quote_email'); $quote_id = isset($_POST['quote_id']) ? intval($_POST['quote_id']) : 0; if (!$quote_id) { $this->sendError(__('Invalid quote ID', 'easy-invoice')); } $repository = \EasyInvoice\Providers\QuoteServiceProvider::getQuoteRepository(); $quote = $repository->find($quote_id); if (!$quote) { $this->sendError(__('Quote not found', 'easy-invoice')); } // Authorisation — same reasoning as the invoice path above. Quotes are also // always stored with post_status 'publish', so findPublished() gated nothing. if (!\EasyInvoice\Controllers\QuoteController::canActOnQuote($quote_id, $quote)) { $this->sendError(__('You do not have permission to access this quote', 'easy-invoice')); } $email_manager = \EasyInvoice\Services\EmailManager::getInstance(); $result = $email_manager->sendQuoteEmail($quote, 'new'); if ($result['success']) { $quote_log_service = new \EasyInvoice\Services\QuoteLogService(); $quote_log_service->logSent($quote_id, $quote->getCustomerEmail()); $this->sendSuccess(array( 'message' => $result['message'], )); } else { $this->sendError($result['message']); } } /** * Generate invoice PDF */ public function generateInvoicePdf() { // Explicitly bust intermediate caching on this admin-ajax URL. Some // page-caching stacks (WP Rocket, LiteSpeed, Cloudflare full-page // cache, some CDNs) will cache a 302 Location header keyed by URL — // the URL always looks the same to the cache because both the nonce // AND the target invoice-permalink change per user, so a first-hit // response can be replayed to other users, breaking the redirect or // returning a blank body. nocache_headers(); header('Cache-Control: no-store, no-cache, must-revalidate, max-age=0'); header('Pragma: no-cache'); // Get invoice ID $invoice_id = isset($_REQUEST['invoice_id']) ? intval($_REQUEST['invoice_id']) : 0; if (!$invoice_id) { $this->sendError(__('Invalid invoice ID', 'easy-invoice')); } // Authorisation with graceful fallback. // // The original design gated this endpoint on a per-request WP // nonce, which is fragile in real deployments: page-caching // layers (WP Rocket, LiteSpeed, Cloudflare full-page cache) // cache the intermediate JSON response that mints the URL, // browser SameSite / ITP behaviour, and admin_url() // scheme-mismatch after login can all cause wp_verify_nonce() // to return false on the intended recipient's tab — leaving // the user stranded on this admin-ajax URL with no download. // // Accept ANY of: // 1. A valid `generate_pdf` nonce (fast path — most users, // most of the time, when the session cookie survives). // 2. An admin session (manage_options) — bypasses the nonce // because the invoice-listing button that creates this // URL is admin-only and the admin owns the request. // 3. A valid per-invoice access token (?ik=) — the // same model canSubmitPaymentForInvoice uses, so emailed // invoice links can also drive a session-less download. // Only when all three paths fail do we refuse. $authorized = false; // Match the same dual-key nonce lookup the removed verifyNonce() // helper did — `nonce` (client-form format used by our JS) AND // `_nonce` (standard WP form field name) — so any external // caller of this endpoint that used _nonce still works. $submitted_nonce = ''; if (isset($_REQUEST['nonce'])) { $submitted_nonce = (string) $_REQUEST['nonce']; } elseif (isset($_REQUEST['_nonce'])) { $submitted_nonce = (string) $_REQUEST['_nonce']; } if ($submitted_nonce !== '' && wp_verify_nonce($submitted_nonce, 'generate_pdf_' . $invoice_id)) { $authorized = true; } elseif (current_user_can('manage_options')) { $authorized = true; } elseif (isset($_REQUEST['ik']) && is_string($_REQUEST['ik'])) { $presented = sanitize_text_field(wp_unslash($_REQUEST['ik'])); $stored = (string) get_post_meta($invoice_id, '_easy_invoice_invoice_access_token', true); if ($stored !== '' && $presented !== '' && hash_equals($stored, $presented)) { $authorized = true; } } if (!$authorized) { $this->sendError(__('Security check failed', 'easy-invoice')); } // Get invoice from repository $repository = InvoiceServiceProvider::getInvoiceRepository(); // For admins, allow access to any invoice status if (current_user_can('manage_options')) { $invoice = $repository->find($invoice_id); } else { // For non-admins, only allow access to published invoices $invoice = $repository->findPublished($invoice_id); } if (!$invoice) { $this->sendError(__('Invoice not found', 'easy-invoice')); } // Redirect to the invoice single page with PDF generation. // Forward the ?ik= access token onwards so the single-page // template can also authorise the recipient (the same token // that got us through Path 3 above). $invoice_url = get_permalink($invoice_id); if (!$invoice_url) { $this->sendError(__('Could not generate invoice URL', 'easy-invoice')); } $target_args = ['auto_download_pdf' => '1']; if (isset($_REQUEST['ik']) && is_string($_REQUEST['ik']) && $_REQUEST['ik'] !== '') { $target_args['ik'] = sanitize_text_field(wp_unslash($_REQUEST['ik'])); } $target_url = add_query_arg($target_args, $invoice_url); $this->redirectWithFallback($target_url); } /** * Generate quote PDF */ public function generateQuotePdf() { // Same cache-busting as generateInvoicePdf — see comment there. nocache_headers(); header('Cache-Control: no-store, no-cache, must-revalidate, max-age=0'); header('Pragma: no-cache'); // Get quote ID $quote_id = isset($_REQUEST['quote_id']) ? intval($_REQUEST['quote_id']) : 0; if (!$quote_id) { $this->sendError(__('Invalid quote ID', 'easy-invoice')); } // Authorisation with graceful fallback. Same three-path model // as generateInvoicePdf — see that method's comment for the // full rationale (nonce fragility across caching layers, // cross-tab session cookie behaviour, etc.). Paths accepted: // // 1. Valid `generate_quote_pdf` nonce (fast path). // 2. Admin session (manage_options) — the quote-listing // button that mints this URL is admin-only. // 3. Valid per-quote access token (?qk=) — mirrors // the CVE-2026-9021 model so emailed quote links can // drive a session-less PDF download. $authorized = false; // Same dual-key nonce lookup as the invoice handler — see // generateInvoicePdf for the backward-compat rationale. $submitted_nonce = ''; if (isset($_REQUEST['nonce'])) { $submitted_nonce = (string) $_REQUEST['nonce']; } elseif (isset($_REQUEST['_nonce'])) { $submitted_nonce = (string) $_REQUEST['_nonce']; } if ($submitted_nonce !== '' && wp_verify_nonce($submitted_nonce, 'generate_quote_pdf_' . $quote_id)) { $authorized = true; } elseif (current_user_can('manage_options')) { $authorized = true; } elseif (isset($_REQUEST['qk']) && is_string($_REQUEST['qk'])) { $presented = sanitize_text_field(wp_unslash($_REQUEST['qk'])); $stored = (string) get_post_meta($quote_id, '_easy_invoice_quote_access_token', true); if ($stored !== '' && $presented !== '' && hash_equals($stored, $presented)) { $authorized = true; } } if (!$authorized) { $this->sendError(__('Security check failed', 'easy-invoice')); } // Get quote from repository — mirror invoice PDF: only published quotes for non-admins (incl. nopriv). $repository = \EasyInvoice\Providers\QuoteServiceProvider::getQuoteRepository(); if (current_user_can('manage_options')) { $quote = $repository->find($quote_id); } else { $quote = $repository->findPublished($quote_id); } if (!$quote) { $this->sendError(__('Quote not found', 'easy-invoice')); } // Redirect to the quote single page with PDF generation. // Forward the ?qk= access token so the single-page template // can also authorise the recipient with the same key that // got us through Path 3. $quote_url = get_permalink($quote_id); if (!$quote_url) { $this->sendError(__('Could not generate quote URL', 'easy-invoice')); } $target_args = ['auto_download_pdf' => '1']; if (isset($_REQUEST['qk']) && is_string($_REQUEST['qk']) && $_REQUEST['qk'] !== '') { $target_args['qk'] = sanitize_text_field(wp_unslash($_REQUEST['qk'])); } $target_url = add_query_arg($target_args, $quote_url); $this->redirectWithFallback($target_url); } /** * Redirect the current request to $url, with a client-side fallback * when the server-side redirect can't fire. * * `wp_safe_redirect()` silently no-ops if headers have already been sent * (BOM in a plugin file, plugin echoing during an action, PHP warning * output, etc.). Because we also `exit;` immediately after, that failure * mode produces a 200 OK with an empty body — the reported blank-page * bug on the invoice-listing PDF download. * * This helper detects the headers-sent case and emits a minimal HTML * document that redirects via meta-refresh (works with JS disabled) and * `window.location.replace()` (JS-enabled, doesn't add a history entry). * Both point at the same escaped URL so misconfigured stacks still get * the user to the target page. */ private function redirectWithFallback(string $url): void { // Suppress cache one more time in case some plugin filtered our // earlier headers away between then and now. nocache_headers(); if (!headers_sent()) { wp_safe_redirect($url); exit; } // Fallback: server-side redirect impossible. Emit a client-side one. $safe_url = esc_url_raw($url); echo ''; echo ''; echo 'Redirecting…'; echo ''; echo ''; echo '

Redirecting to ' . esc_html($safe_url) . '

'; echo ''; exit; } /** * Search clients for the dropdown */ public function searchClients() { $this->verifyNonce('easy_invoice_nonce'); if (!easy_invoice_user_can('ei_view_clients')) { $this->sendError(__('You do not have permission to perform this action', 'easy-invoice')); } $query = isset($_POST['query']) ? sanitize_text_field($_POST['query']) : ''; // Get client repository $client_repository = \EasyInvoice\Providers\ClientServiceProvider::getClientRepository(); // Search clients $clients = $client_repository->search($query); // Row-level security: restrict to assigned clients for Sales reps // (users with ei_view_clients but no ei_view_all_clients). Null // return = unrestricted, no-op. if (function_exists('easy_invoice_visible_client_ids')) { $visible = easy_invoice_visible_client_ids(); if (is_array($visible)) { $allowed = array_flip(array_map('intval', $visible)); $clients = array_values(array_filter($clients, static function ($c) use ($allowed) { return isset($allowed[(int) $c->getId()]); })); } } // Bypass $this->sendSuccess() — search is a read endpoint and // shouldn't show "Operation completed successfully" toasts on // every keystroke. Use wp_send_json_success directly. if (empty($clients)) { wp_send_json_success(array()); } // Format clients for dropdown $formatted_clients = array(); foreach ($clients as $client) { // Get the WordPress user data directly $user = get_user_by('id', $client->getId()); if (!$user) { continue; } // Use Client model properties first, fallback to WordPress user fields $business_name = $client->business_client_name ?: ''; $first_name = $client->first_name ?: $user->first_name ?: ''; $last_name = $client->last_name ?: $user->last_name ?: ''; $email = $client->email ?: $user->user_email ?: ''; // Create display name $client_name = $business_name ?: ($first_name . ' ' . $last_name); if (empty(trim($client_name))) { $client_name = $user->display_name ?: 'User ' . $client->getId(); } // Include all clients, even those with empty emails $formatted_clients[] = array( 'id' => $client->getId(), 'name' => $client_name, 'email' => $email, 'company' => $business_name, 'phone' => $client->phone ?: '', 'address' => $client->address ?: '', 'website' => $client->website ?: '', 'display_name' => $client_name . ' (' . $email . ')' ); } wp_send_json_success($formatted_clients); } /** * Save additional CSS for invoice/quote */ public function saveAdditionalCSS() { // Verify nonce if (!wp_verify_nonce(($_POST['nonce'] ?? ''), 'save_additional_css_nonce')) { $this->sendError('Security check failed'); return; } // Check user capabilities - require administrator if (!current_user_can('manage_options')) { $this->sendError('You do not have permission to perform this action'); return; } // Validate and sanitize post ID $post_id = isset($_POST['post_id']) ? intval($_POST['post_id']) : 0; if ($post_id <= 0) { $this->sendError('Invalid post ID'); return; } // Verify post exists and user can edit it $post = get_post($post_id); if (!$post || !current_user_can('edit_post', $post_id)) { $this->sendError('You cannot edit this post'); return; } // Verify post type is invoice or quote $valid_post_types = [ \EasyInvoice\Constants\PostTypes::EASY_INVOICE_POST_TYPE, \EasyInvoice\Constants\PostTypes::EASY_INVOICE_QUOTE_POST_TYPE ]; if (!in_array($post->post_type, $valid_post_types)) { $this->sendError('Invalid post type'); return; } // Get and sanitize CSS content $css = isset($_POST['css']) ? $_POST['css'] : ''; // Enhanced CSS sanitization $css = $this->sanitizeCSS($css); // Limit CSS length to prevent abuse if (strlen($css) > 50000) { // 50KB limit $this->sendError('CSS content too long'); return; } // Save CSS to post meta $result = update_post_meta($post_id, '_easy_invoice_additional_css', $css); if ($result !== false) { $this->sendSuccess(array( 'message' => 'CSS saved successfully', 'css' => $css, 'post_id' => $post_id )); } else { $this->sendError('Failed to save CSS'); } } /** * Enhanced CSS sanitization - preserves valid CSS while removing threats */ private function sanitizeCSS($css) { // Remove PHP tags first $css = preg_replace('/<\?php.*?\?>/is', '', $css); // Remove HTML tags (script, iframe, object, embed) $css = preg_replace('/]*>.*?<\/script>/is', '', $css); $css = preg_replace('/]*>.*?<\/iframe>/is', '', $css); $css = preg_replace('/]*>.*?<\/object>/is', '', $css); $css = preg_replace('/]*>/is', '', $css); // Remove dangerous CSS constructs $css = preg_replace('/expression\s*\(/i', '', $css); // CSS expressions $css = preg_replace('/javascript\s*:/i', '', $css); // JavaScript protocol $css = preg_replace('/@import\s+url\s*\(/i', '', $css); // @import url() $css = preg_replace('/@import\s+["\'][^"\']+["\']/', '', $css); // @import with quotes $css = preg_replace('/behavior\s*:\s*url\s*\(/i', '', $css); // IE behavior $css = preg_replace('/binding\s*:/i', '', $css); // XBL binding // Remove dangerous CSS functions (but keep safe ones) $dangerous_functions = ['eval', 'exec', 'system', 'passthru', 'shell_exec', 'phpinfo', 'file_get_contents', 'file_put_contents', 'fopen', 'fwrite', 'curl_exec']; foreach ($dangerous_functions as $func) { $css = preg_replace('/\b' . preg_quote($func, '/') . '\s*\(/i', '', $css); } // Remove data URLs that could contain malicious content $css = preg_replace('/data\s*:\s*["\'][^"\']*["\']/i', '', $css); // Remove vbscript: protocol $css = preg_replace('/vbscript\s*:/i', '', $css); // Remove any remaining HTML-like constructs $css = htmlspecialchars_decode($css, ENT_QUOTES); // Basic cleanup - remove excessive whitespace but preserve CSS structure $css = preg_replace('/\s+/', ' ', $css); $css = preg_replace('/;\s*}/', '}', $css); $css = preg_replace('/\s*{\s*/', ' {', $css); $css = preg_replace('/;\s*;/', ';', $css); return trim($css); } }