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

EasyInvoiceAjax.php in Easy Invoice – Invoice Generator, PDF Quotes & Payments 2.3.2, at includes/Admin/EasyInvoiceAjax.php

1,668 lines 62.7 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2 /**
3 * EasyInvoice AJAX Class
4 *
5 * @package Easy_Invoice
6 * @subpackage Admin
7 */
8
9 namespace EasyInvoice\Admin;
10
11 use EasyInvoice\Models\Invoice;
12 use EasyInvoice\Models\InvoiceItem;
13 use EasyInvoice\Providers\InvoiceServiceProvider;
14 use EasyInvoice\Providers\ClientServiceProvider;
15 use EasyInvoice\Constants\ClientFields;
16
17 /**
18 * EasyInvoiceAjax Class
19 *
20 * Handles all AJAX functionality for the plugin.
21 */
22 class EasyInvoiceAjax {
23 /**
24 * Initialize AJAX handlers
25 */
26 public function init() {
27 // Invoice actions
28 add_action('wp_ajax_easy_invoice_delete', array($this, 'deleteInvoice'));
29 add_action('wp_ajax_easy_invoice_get', array($this, 'getInvoice'));
30 add_action('wp_ajax_easy_invoice_save_invoice', array($this, 'saveInvoice'));
31 add_action('wp_ajax_easy_invoice_save_and_send_invoice', array($this, 'saveAndSendInvoice'));
32
33 // Template actions
34
35
36
37 // Document and email actions
38 add_action('wp_ajax_easy_invoice_download_pdf', array($this, 'downloadPdf'));
39 add_action('wp_ajax_easy_invoice_send_email', array($this, 'sendInvoiceEmail'));
40
41 // Single page actions (for public access)
42 add_action('wp_ajax_easy_invoice_download_invoice_pdf', array($this, 'downloadInvoicePdf'));
43 add_action('wp_ajax_easy_invoice_send_invoice_email', array($this, 'sendInvoiceEmailPublic'));
44 add_action('wp_ajax_easy_invoice_send_quote_email', array($this, 'sendQuoteEmailPublic'));
45 add_action('wp_ajax_nopriv_easy_invoice_download_invoice_pdf', array($this, 'downloadInvoicePdf'));
46 add_action('wp_ajax_nopriv_easy_invoice_send_invoice_email', array($this, 'sendInvoiceEmailPublic'));
47 add_action('wp_ajax_nopriv_easy_invoice_send_quote_email', array($this, 'sendQuoteEmailPublic'));
48
49 // PDF generation actions
50 add_action('wp_ajax_easy_invoice_generate_pdf', array($this, 'generateInvoicePdf'));
51 add_action('wp_ajax_easy_invoice_generate_quote_pdf', array($this, 'generateQuotePdf'));
52
53 // Additional CSS actions
54 add_action('wp_ajax_save_additional_css', array($this, 'saveAdditionalCSS'));
55 add_action('wp_ajax_nopriv_easy_invoice_generate_pdf', array($this, 'generateInvoicePdf'));
56 add_action('wp_ajax_nopriv_easy_invoice_generate_quote_pdf', array($this, 'generateQuotePdf'));
57
58 // Quote document actions
59 add_action('wp_ajax_easy_invoice_download_quote_pdf', array($this, 'downloadQuotePdf'));
60 add_action('wp_ajax_easy_invoice_save_quote', array($this, 'saveQuote'));
61
62 // Client actions
63 add_action('wp_ajax_easy_invoice_save_client', array($this, 'saveClient'));
64 add_action('wp_ajax_easy_invoice_delete_client', array($this, 'deleteClient'));
65 add_action('wp_ajax_easy_invoice_get_client', array($this, 'getClient'));
66 add_action('wp_ajax_easy_invoice_add_client', array($this, 'addClient'));
67 add_action('wp_ajax_easy_invoice_update_client', array($this, 'updateClient'));
68 add_action('wp_ajax_easy_invoice_check_email_exists', array($this, 'checkEmailExists'));
69 add_action('wp_ajax_easy_invoice_generate_password', array($this, 'generatePassword'));
70 add_action('wp_ajax_easy_invoice_search_clients', array($this, 'searchClients'));
71 add_action('wp_ajax_easy_invoice_update_invoices_data', array($this, 'updateInvoicesData'));
72 }
73
74 /**
75 * Save invoice
76 */
77 public function saveInvoice() {
78 $this->verifyNonce('easy_invoice_nonce');
79
80 if (!easy_invoice_user_can('ei_create_invoice')) {
81 $this->sendError(__('You do not have permission to perform this action', 'easy-invoice'));
82 }
83
84 // Get the raw invoice data from the form
85 $raw_invoice_data = isset($_POST['invoice_data']) ? $_POST['invoice_data'] : $_POST;
86
87 // Remove non-invoice fields
88 unset($raw_invoice_data['action']);
89 unset($raw_invoice_data['nonce']);
90
91 // Process the invoice data
92 $invoice_form_manager = new \EasyInvoice\Forms\Invoice\InvoiceFormManager();
93 $invoice_data = $invoice_form_manager->processFormData($raw_invoice_data);
94
95 if (!empty($invoice_data['errors'])) {
96 wp_send_json_error([
97 'message' => 'Validation failed',
98 'errors' => $invoice_data['errors']
99 ]);
100 }
101
102 // Handle items separately - process the natural form submission format
103 if (isset($raw_invoice_data['items']) && is_array($raw_invoice_data['items'])) {
104 // Form submits items as items[0][title], items[0][description], etc.
105 // Convert to array of item objects for processing
106 $items_array = [];
107 foreach ($raw_invoice_data['items'] as $index => $item_data) {
108 if (is_array($item_data)) {
109 $items_array[] = $item_data;
110 }
111 }
112
113 // Process items using the dynamic field system
114 $invoice_data['data']['items'] = $invoice_form_manager->processItemsData($items_array);
115 }
116
117 // Handle special fields that might not be in the form definition
118 if (isset($raw_invoice_data['invoice_id'])) {
119 $invoice_data['data']['invoice_id'] = intval($raw_invoice_data['invoice_id']);
120 }
121
122 if (isset($raw_invoice_data['client_id'])) {
123 $invoice_data['data']['client_id'] = intval($raw_invoice_data['client_id']);
124 }
125
126
127
128 $invoice_id = isset($invoice_data['data']['invoice_id']) ? intval($invoice_data['data']['invoice_id']) : 0;
129
130 $repository = InvoiceServiceProvider::getInvoiceRepository();
131
132 if ($invoice_id > 0) {
133 // Update existing invoice - preserve existing invoice number
134 unset($invoice_data['data']['invoice_number']);
135 unset($invoice_data['data']['number']);
136
137 $invoice = $repository->update($invoice_id, $invoice_data['data']);
138
139 if (!$invoice) {
140
141 $this->sendError(__('Failed to update invoice', 'easy-invoice'));
142 }
143
144 // Use FormProcessor to save form data to database
145 $form_processor = new \EasyInvoice\Forms\FormProcessor();
146 $all_fields = $invoice_form_manager->getAllFields();
147 $form_processor->saveFormDataToDatabase($invoice_data['data'], $all_fields, $invoice);
148
149 $message = __('Invoice updated successfully', 'easy-invoice');
150 } else {
151 // Create new invoice - allow auto-generated invoice number to be saved
152 // The invoice number will be auto-generated by the form and included in the data
153
154 $invoice = $repository->create($invoice_data['data']);
155
156
157 if (!$invoice) {
158 $this->sendError(__('Failed to create invoice', 'easy-invoice'));
159 }
160
161 // Use FormProcessor to save form data to database
162 $form_processor = new \EasyInvoice\Forms\FormProcessor();
163 $all_fields = $invoice_form_manager->getAllFields();
164 $form_processor->saveFormDataToDatabase($invoice_data['data'], $all_fields, $invoice);
165
166 $invoice_id = $invoice->getId();
167 $message = __('Invoice created successfully', 'easy-invoice');
168 }
169
170 // Handle items
171 if (isset($invoice_data['data']['items']) && is_array($invoice_data['data']['items'])) {
172 $invoice->setItems($invoice_data['data']['items']);
173 }
174
175 $invoice_template = get_post_meta($invoice_id, '_easy_invoice_invoice_template', true);
176
177 $invoice_template = $invoice_template=='' ? 'standard': $invoice_template;
178
179 update_option('easy_invoice_last_invoice_template',$invoice_template );
180
181 // Prepare response data
182 $response_data = array(
183 'invoice_id' => $invoice_id,
184 'invoice' => $invoice->toArray(),
185 'toast' => array(
186 'type' => 'success',
187 'message' => $message,
188 'options' => array('duration' => 4000)
189 )
190 );
191
192 // Include client data if invoice has a client
193 if ($invoice->getClientId()) {
194 $client_repository = ClientServiceProvider::getClientRepository();
195 $client = $client_repository->find($invoice->getClientId());
196 if ($client) {
197 $response_data['client'] = array(
198 'id' => $client->getId(),
199 'name' => $client->getBusinessClientName() ?: ($client->getFirstName() . ' ' . $client->getLastName()),
200 'email' => $client->getEmail() ?: '',
201 'phone' => $client->getExtraInfo() ?: '',
202 'company' => $client->getBusinessClientName() ?: '',
203 'address' => $client->getAddress() ?: '',
204 'website' => $client->getWebsite() ?: '',
205 );
206 }
207 }
208
209 wp_send_json_success($response_data);
210 }
211
212 /**
213 * Save and send invoice
214 */
215 public function saveAndSendInvoice() {
216 $this->verifyNonce('easy_invoice_nonce');
217
218 // Compound action: needs both create-edit and send rights.
219 if (!easy_invoice_user_can('ei_create_invoice') || !easy_invoice_user_can('ei_send_invoice')) {
220 $this->sendError(__('You do not have permission to perform this action', 'easy-invoice'));
221 }
222
223 // First save the invoice
224 $this->saveInvoice();
225
226 // If we get here, the invoice was saved successfully
227 // Now send the invoice via email
228 $invoice_id = isset($_POST['invoice_data']['invoice_id']) ? intval($_POST['invoice_data']['invoice_id']) : 0;
229
230 if ($invoice_id > 0) {
231 // Send the invoice via email
232 $result = $this->sendInvoiceEmail($invoice_id);
233
234 if ($result['success']) {
235 $this->sendSuccess(array(
236 'message' => __('Invoice saved and sent successfully', 'easy-invoice'),
237 'invoice_id' => $invoice_id
238 ));
239 } else {
240 $this->sendError($result['message']);
241 }
242 } else {
243 $this->sendError(__('Invalid invoice ID for sending', 'easy-invoice'));
244 }
245 }
246
247 /**
248 * Delete invoice
249 */
250 public function deleteInvoice() {
251 $this->verifyNonce('easy_invoice_nonce');
252
253 if (!easy_invoice_user_can('ei_delete_invoice')) {
254 $this->sendError(__('You do not have permission to perform this action', 'easy-invoice'));
255 }
256
257 $invoice_id = isset($_POST['invoice_id']) ? intval($_POST['invoice_id']) : 0;
258
259 if ($invoice_id <= 0) {
260 $this->sendError(__('Invalid invoice ID', 'easy-invoice'));
261 }
262
263 $repository = InvoiceServiceProvider::getInvoiceRepository();
264 $result = $repository->delete($invoice_id);
265
266 if (!$result) {
267 $this->sendError(__('Failed to delete invoice', 'easy-invoice'));
268 }
269
270 $this->sendSuccess(array(
271 'message' => __('Invoice deleted successfully', 'easy-invoice'),
272 'invoice_id' => $invoice_id,
273 ));
274 }
275
276 /**
277 * Get invoice
278 */
279 public function getInvoice() {
280 $this->verifyNonce('easy_invoice_nonce');
281
282 if (!easy_invoice_user_can('ei_view_invoices')) {
283 $this->sendError(__('You do not have permission to perform this action', 'easy-invoice'));
284 }
285
286 $invoice_id = isset($_REQUEST['invoice_id']) ? intval($_REQUEST['invoice_id']) : 0;
287
288 if ($invoice_id <= 0) {
289 $this->sendError(__('Invalid invoice ID', 'easy-invoice'));
290 }
291
292 $repository = InvoiceServiceProvider::getInvoiceRepository();
293 $invoice = $repository->find($invoice_id);
294
295 if (!$invoice) {
296 $this->sendError(__('Invoice not found', 'easy-invoice'));
297 }
298
299 $this->sendSuccess(array(
300 'invoice' => $invoice->toArray(),
301 ));
302 }
303
304 /**
305 * Save client
306 */
307 public function saveClient() {
308 $this->verifyNonce('easy_invoice_nonce');
309
310 if (!easy_invoice_user_can('ei_manage_clients')) {
311 $this->sendError(__('You do not have permission to perform this action', 'easy-invoice'));
312 }
313
314 $client_id = isset($_POST['client_id']) ? intval($_POST['client_id']) : 0;
315 $client_data = isset($_POST['client_data']) ? $this->sanitizeData($_POST['client_data']) : array();
316
317 if (empty($client_data)) {
318 $this->sendError(__('Invalid client data', 'easy-invoice'));
319 }
320
321 $repository = ClientServiceProvider::getClientRepository();
322
323 if ($client_id > 0) {
324 // Update existing client
325 $client = $repository->update($client_id, $client_data);
326
327 if (!$client) {
328 $this->sendError(__('Failed to update client', 'easy-invoice'));
329 }
330
331 $message = __('Client updated successfully', 'easy-invoice');
332 } else {
333 // Create new client
334 $client = $repository->create($client_data);
335
336 if (!$client) {
337 $this->sendError(__('Failed to create client', 'easy-invoice'));
338 }
339
340 $client_id = $client->getId();
341 $message = __('Client created successfully', 'easy-invoice');
342 }
343
344 $this->sendSuccess(array(
345 'message' => $message,
346 'client_id' => $client_id,
347 'client' => $client->toArray(),
348 ));
349 }
350
351 /**
352 * Delete client
353 */
354 public function deleteClient() {
355 try {
356 $this->verifyNonce('easy_invoice_nonce');
357
358 if (!easy_invoice_user_can('ei_manage_clients')) {
359 $this->sendError(__('You do not have permission to perform this action', 'easy-invoice'));
360 }
361
362 $client_id = isset($_POST['client_id']) ? intval($_POST['client_id']) : 0;
363 $delete_associated_documents = isset($_POST['delete_associated_documents']) ? (bool)$_POST['delete_associated_documents'] : false;
364
365 if ($client_id <= 0) {
366 $this->sendError(__('Invalid client ID', 'easy-invoice'));
367 }
368
369 // Check if the user exists and is not an administrator
370 $user = get_user_by('ID', $client_id);
371 if (!$user) {
372 $this->sendError(__('User not found', 'easy-invoice'));
373 }
374
375 if (in_array('administrator', $user->roles)) {
376 $this->sendError(__('Cannot delete administrator accounts', 'easy-invoice'));
377 }
378
379 global $wpdb;
380
381 // Get counts of associated documents
382 $invoice_count = $wpdb->get_var($wpdb->prepare(
383 "SELECT COUNT(*) FROM {$wpdb->postmeta} WHERE meta_key = '_easy_invoice_client_id' AND meta_value = %d",
384 $client_id
385 ));
386
387 $quote_count = $wpdb->get_var($wpdb->prepare(
388 "SELECT COUNT(*) FROM {$wpdb->postmeta} WHERE meta_key = '_easy_invoice_quote_client_id' AND meta_value = %d",
389 $client_id
390 ));
391
392 $payment_count = $wpdb->get_var($wpdb->prepare(
393 "SELECT COUNT(*) FROM {$wpdb->postmeta} WHERE meta_key = '_easy_payment_client_id' AND meta_value = %d",
394 $client_id
395 ));
396
397 $total_documents = $invoice_count + $quote_count + $payment_count;
398
399 if ($delete_associated_documents) {
400 // Delete all associated documents
401 $this->log(sprintf('Deleting client %d with all associated documents (%d invoices, %d quotes, %d payments)',
402 $client_id, $invoice_count, $quote_count, $payment_count));
403
404 // Delete invoices
405 if ($invoice_count > 0) {
406 $invoices = $wpdb->get_col($wpdb->prepare(
407 "SELECT post_id FROM {$wpdb->postmeta} WHERE meta_key = '_easy_invoice_client_id' AND meta_value = %d",
408 $client_id
409 ));
410 foreach ($invoices as $invoice_id) {
411 wp_delete_post($invoice_id, true);
412 }
413 }
414
415 // Delete quotes
416 if ($quote_count > 0) {
417 $quotes = $wpdb->get_col($wpdb->prepare(
418 "SELECT post_id FROM {$wpdb->postmeta} WHERE meta_key = '_easy_invoice_quote_client_id' AND meta_value = %d",
419 $client_id
420 ));
421 foreach ($quotes as $quote_id) {
422 wp_delete_post($quote_id, true);
423 }
424 }
425
426 // Delete payments
427 if ($payment_count > 0) {
428 $payments = $wpdb->get_col($wpdb->prepare(
429 "SELECT post_id FROM {$wpdb->postmeta} WHERE meta_key = '_easy_invoice_payment_client_id' AND meta_value = %d",
430 $client_id
431 ));
432 foreach ($payments as $payment_id) {
433 wp_delete_post($payment_id, true);
434 }
435 }
436
437 $message = sprintf(__('Client and all associated documents (%d total) deleted successfully', 'easy-invoice'), $total_documents);
438 } else {
439 // Only remove client associations, preserve documents
440 $this->log(sprintf('Removing client associations for client %d (%d invoices, %d quotes, %d payments)',
441 $client_id, $invoice_count, $quote_count, $payment_count));
442
443 // Remove client associations from invoices
444 if ($invoice_count > 0) {
445 $wpdb->delete(
446 $wpdb->postmeta,
447 ['meta_key' => '_easy_invoice_client_id', 'meta_value' => $client_id]
448 );
449 }
450
451 // Remove client associations from quotes
452 if ($quote_count > 0) {
453 $wpdb->delete(
454 $wpdb->postmeta,
455 ['meta_key' => '_easy_invoice_quote_client_id', 'meta_value' => $client_id]
456 );
457 }
458
459 // Remove client associations from payments
460 if ($payment_count > 0) {
461 $wpdb->delete(
462 $wpdb->postmeta,
463 ['meta_key' => '_easy_payment_client_id', 'meta_value' => $client_id]
464 );
465 }
466
467 $message = sprintf(__('Client deleted successfully. %d documents preserved but client associations removed.', 'easy-invoice'), $total_documents);
468 }
469
470 // Snapshot identity BEFORE delete — once wp_delete_user runs the
471 // user record is gone and we can't backfill the audit context.
472 $deleted_login = $user && $user->user_login ? $user->user_login : '';
473 $deleted_email = $user && $user->user_email ? $user->user_email : '';
474
475 // Delete the WordPress user
476 require_once(ABSPATH . 'wp-admin/includes/user.php');
477 $result = wp_delete_user($client_id);
478
479 if (!$result) {
480 $this->sendError(__('Failed to delete client', 'easy-invoice'));
481 }
482
483 // Audit: record the delete with enough context to investigate later.
484 if (function_exists('easy_invoice_audit_log')) {
485 easy_invoice_audit_log('client_deleted', 'client', $client_id, [
486 'login' => $deleted_login,
487 'email' => $deleted_email,
488 'invoices_affected' => (int) $invoice_count,
489 'quotes_affected' => (int) $quote_count,
490 'payments_affected' => (int) $payment_count,
491 'cascade_delete' => $delete_associated_documents,
492 ]);
493 }
494
495 $this->sendSuccess(array(
496 'message' => $message,
497 'client_id' => $client_id,
498 'documents_deleted' => $delete_associated_documents,
499 'total_documents' => $total_documents
500 ));
501
502 } catch (\Exception $e) {
503 $this->sendError($e->getMessage());
504 }
505 }
506
507 /**
508 * Get client
509 */
510 public function getClient() {
511 $this->verifyNonce('easy_invoice_nonce');
512
513 if (!easy_invoice_user_can('ei_view_clients')) {
514 $this->sendError(__('You do not have permission to perform this action', 'easy-invoice'));
515 }
516
517 $client_id = isset($_REQUEST['client_id']) ? intval($_REQUEST['client_id']) : 0;
518
519 if ($client_id <= 0) {
520 $this->sendError(__('Invalid client ID', 'easy-invoice'));
521 }
522
523 $repository = ClientServiceProvider::getClientRepository();
524 $client = $repository->find($client_id);
525
526 if (!$client) {
527 $this->sendError(__('Client not found', 'easy-invoice'));
528 }
529
530 $client_data = $client->toArray();
531
532 // Return comprehensive client data in a unified format that works for both form population and display
533 $this->sendSuccess(array(
534 // Form population fields (for invoice-builder.js and invoice-form.js)
535 'name' => $client_data['company_name'] ?? $client_data['contact_name'] ?? '',
536 'email' => $client_data['email'] ?? '',
537 'phone' => $client_data['phone'] ?? '',
538 'company' => $client_data['company_name'] ?? '',
539 'address' => $client_data['billing_address'] ?? '',
540 'website' => $client_data['website'] ?? '',
541
542 // Display fields (for client-manager.js)
543 'business_client_name' => $client->getBusinessClientName(),
544 'username' => $client->getUsername(),
545 'extra_info' => $client->getExtraInfo(),
546 'first_name' => $client->getFirstName(),
547 'last_name' => $client->getLastName(),
548
549 // Raw data for backward compatibility
550 'client' => array(
551 'name' => $client_data['company_name'] ?? $client_data['contact_name'] ?? '',
552 'email' => $client_data['email'] ?? '',
553 'phone' => $client_data['phone'] ?? '',
554 'company' => $client_data['company_name'] ?? '',
555 'address' => $client_data['billing_address'] ?? '',
556 'website' => $client_data['website'] ?? '',
557 )
558 ));
559 }
560
561 /**
562 * Verify nonce
563 *
564 * @param string $action The nonce action
565 */
566 private function verifyNonce($action) {
567 // Check for _nonce (standard format) first
568 if (isset($_REQUEST['_nonce']) && wp_verify_nonce($_REQUEST['_nonce'], $action)) {
569 return;
570 }
571
572 // Also check for 'nonce' (client form format)
573 if (isset($_REQUEST['nonce']) && wp_verify_nonce($_REQUEST['nonce'], $action)) {
574 return;
575 }
576
577 // If we get here, neither nonce format was valid
578 $this->sendError(__('Security check failed', 'easy-invoice'));
579 }
580
581 /**
582 * Sanitize data
583 *
584 * @param array $data The data to sanitize
585 * @return array The sanitized data
586 */
587 private function sanitizeData($data) {
588 if (!is_array($data)) {
589 return array();
590 }
591
592 $sanitized = array();
593
594 // Define fields that should allow HTML (like textarea content)
595 $html_fields = [
596 'invoice_description', 'description', 'notes', 'terms',
597 'internal_notes', 'customer_address'
598 ];
599
600 // Define numeric fields
601 $numeric_fields = [
602 'invoice_id', 'client_id', 'discount_value', 'tax_rate'
603 ];
604
605 foreach ($data as $key => $value) {
606 if (is_array($value)) {
607 $sanitized[$key] = $this->sanitizeData($value);
608 } else if (in_array($key, $html_fields)) {
609 // For HTML fields, use wp_kses to allow certain tags but prevent XSS
610 $sanitized[$key] = wp_kses_post($value);
611 } else if (in_array($key, $numeric_fields)) {
612 // For numeric fields, ensure they're valid numbers
613 $sanitized[$key] = is_numeric($value) ? $value : 0;
614 } else {
615 $sanitized[$key] = sanitize_text_field($value);
616 }
617 }
618
619 return $sanitized;
620 }
621
622 /**
623 * Send success response
624 */
625 private function sendSuccess($data = array()) {
626 // Check if we should suppress global toast
627 $suppress_toast = isset($_POST['suppress_global_toast']) && $_POST['suppress_global_toast'] === 'true';
628
629 // Only inject the toast key when $data is an associative array
630 // (or empty). If $data is a numeric-indexed list (e.g. search
631 // results), adding a string key would mutate the array shape:
632 // PHP keeps the mixed keys, but `wp_send_json_success` then
633 // serialises the value as a JSON OBJECT instead of an array,
634 // breaking any frontend that does `response.data.length` or
635 // `response.data.forEach(...)` — the exact bug that caused the
636 // client-search dropdown to silently render empty results.
637 $is_assoc_or_empty = !is_array($data)
638 || empty($data)
639 || array_keys($data) !== range(0, count($data) - 1);
640
641 if ($is_assoc_or_empty && !isset($data['toast']) && !$suppress_toast) {
642 $message = isset($data['message']) ? $data['message'] : __('Operation completed successfully', 'easy-invoice');
643 $data['toast'] = array(
644 'type' => 'success',
645 'message' => $message,
646 'options' => array('duration' => 4000)
647 );
648 }
649
650 // Remove toast data if suppressed
651 if ($is_assoc_or_empty && $suppress_toast && isset($data['toast'])) {
652 unset($data['toast']);
653 }
654
655 wp_send_json_success($data);
656 }
657
658 /**
659 * Send error response
660 */
661 private function sendError($message, $data = array()) {
662 // Add toast notification
663 $data['toast'] = array(
664 'type' => 'error',
665 'message' => $message,
666 'options' => array('duration' => 6000)
667 );
668
669 wp_send_json_error($data);
670 }
671
672 /**
673 * Download invoice as PDF
674 */
675 public function downloadPdf() {
676 // Verify nonce
677 $this->verifyNonce('easy_invoice_nonce');
678
679 // Check if user has required capability
680 if (!easy_invoice_user_can('ei_view_invoices')) {
681 $this->sendError(__('You do not have permission to download invoices', 'easy-invoice'));
682 }
683
684 // Get invoice ID
685 $invoice_id = isset($_REQUEST['invoice_id']) ? intval($_REQUEST['invoice_id']) : 0;
686
687 if (!$invoice_id) {
688 $this->sendError(__('Invalid invoice ID', 'easy-invoice'));
689 }
690
691 // Get invoice from repository
692 $repository = InvoiceServiceProvider::getInvoiceRepository();
693 $invoice = $repository->find($invoice_id);
694
695 if (!$invoice) {
696 $this->sendError(__('Invoice not found', 'easy-invoice'));
697 }
698
699 // Get invoice data for PDF generation
700 $invoice_data = \EasyInvoice\Includes\Helpers\PdfHelper::getInvoiceDataForPdf($invoice);
701
702 // Return success response with invoice data
703 $this->sendSuccess(array(
704 'message' => __('Invoice data retrieved successfully', 'easy-invoice'),
705 'invoice_data' => $invoice_data
706 ));
707 }
708
709 /**
710 * Send invoice via email
711 */
712 public function sendInvoiceEmail() {
713 $this->verifyNonce('easy_invoice_nonce');
714
715 if (!easy_invoice_user_can('ei_send_invoice')) {
716 $this->sendError(__('You do not have permission to perform this action', 'easy-invoice'));
717 }
718
719 // Get invoice ID from POST data
720 $invoice_id = isset($_POST['invoice_id']) ? intval($_POST['invoice_id']) : 0;
721
722 if (!$invoice_id) {
723 $this->sendError(__('Invalid invoice ID', 'easy-invoice'));
724 }
725
726 $repository = InvoiceServiceProvider::getInvoiceRepository();
727 $invoice = $repository->find($invoice_id);
728
729 if (!$invoice) {
730 $this->sendError(__('Invoice not found', 'easy-invoice'));
731 }
732
733 // Use EmailManager to send the email
734 $email_manager = \EasyInvoice\Services\EmailManager::getInstance();
735 $result = $email_manager->sendInvoiceEmail($invoice, 'new');
736
737 if ($result['success']) {
738 // Audit: who sent which invoice to which client, at what time.
739 if (function_exists('easy_invoice_audit_log')) {
740 easy_invoice_audit_log('invoice_sent', 'invoice', $invoice_id, [
741 'recipient' => method_exists($invoice, 'getCustomerEmail') ? $invoice->getCustomerEmail() : '',
742 'context' => 'new',
743 ]);
744 }
745 $this->sendSuccess(array(
746 'message' => $result['message']
747 ));
748 } else {
749 $this->sendError($result['message']);
750 }
751 }
752
753 /**
754 * Download quote as PDF
755 */
756 public function downloadQuotePdf() {
757 // Verify nonce
758 $this->verifyNonce('easy_invoice_nonce');
759
760 // Check if user has required capability
761 if (!easy_invoice_user_can('ei_view_quotes')) {
762 $this->sendError(__('You do not have permission to download quotes', 'easy-invoice'));
763 }
764
765 // Get quote ID
766 $quote_id = isset($_POST['quote_id']) ? intval($_POST['quote_id']) : 0;
767
768 if (!$quote_id) {
769 $this->sendError(__('Invalid quote ID', 'easy-invoice'));
770 }
771
772 // Get quote from repository
773 $repository = \EasyInvoice\Providers\QuoteServiceProvider::getQuoteRepository();
774 $quote = $repository->find($quote_id);
775
776 if (!$quote) {
777 $this->sendError(__('Quote not found', 'easy-invoice'));
778 }
779
780 // For now, return success response with quote data
781 // PDF generation can be implemented later with actual PDF creation
782 $this->sendSuccess(array(
783 'message' => __('Quote data retrieved successfully', 'easy-invoice'),
784 'quote_data' => $quote->toArray(),
785 'download_url' => add_query_arg(array(
786 'action' => 'easy_invoice_generate_quote_pdf',
787 'quote_id' => $quote_id,
788 'nonce' => wp_create_nonce('generate_quote_pdf')
789 ), admin_url('admin-ajax.php'))
790 ));
791 }
792
793 /**
794 * Save quote
795 */
796 public function saveQuote() {
797 $this->verifyNonce('easy_invoice_nonce');
798
799 if (!easy_invoice_user_can('ei_create_quote')) {
800 $this->sendError(__('You do not have permission to perform this action', 'easy-invoice'));
801 }
802
803 // Get the raw quote data from the form
804 $raw_quote_data = isset($_POST['quote_data']) ? $_POST['quote_data'] : $_POST;
805
806 // Remove non-quote fields
807 unset($raw_quote_data['action']);
808 unset($raw_quote_data['nonce']);
809
810 // Process the quote data
811 $quote_form_manager = new \EasyInvoice\Forms\Quote\QuoteFormManager();
812 $quote_data = $quote_form_manager->processFormData($raw_quote_data);
813
814 if (!empty($quote_data['errors'])) {
815 wp_send_json_error([
816 'message' => 'Validation failed',
817 'errors' => $quote_data['errors']
818 ]);
819 }
820
821 // Handle items separately - process the natural form submission format
822 if (isset($raw_quote_data['items']) && is_array($raw_quote_data['items'])) {
823 // Form submits items as items[0][title], items[0][description], etc.
824 // Convert to array of item objects for processing
825 $items_array = [];
826 foreach ($raw_quote_data['items'] as $index => $item_data) {
827 if (is_array($item_data)) {
828 $items_array[] = $item_data;
829 }
830 }
831
832 // Process items using the dynamic field system
833 $quote_data['data']['items'] = $quote_form_manager->processItemsData($items_array);
834 }
835
836 // Handle special fields that might not be in the form definition
837 if (isset($raw_quote_data['quote_id'])) {
838 $quote_data['data']['quote_id'] = intval($raw_quote_data['quote_id']);
839 }
840
841 if (isset($raw_quote_data['client_id'])) {
842 $quote_data['data']['client_id'] = intval($raw_quote_data['client_id']);
843 }
844
845
846
847 $quote_id = isset($quote_data['data']['quote_id']) ? intval($quote_data['data']['quote_id']) : 0;
848
849 $repository = \EasyInvoice\Providers\QuoteServiceProvider::getQuoteRepository();
850
851 if ($quote_id > 0) {
852 // Update existing quote - preserve existing quote number
853 unset($quote_data['data']['quote_number']);
854 unset($quote_data['data']['number']);
855
856 // Get the existing quote first
857 $quote = $repository->find($quote_id);
858
859 if (!$quote) {
860 $this->sendError(__('Failed to find quote for update', 'easy-invoice'));
861 }
862
863 // Use FormProcessor to save form data to database BEFORE repository update
864 $form_processor = new \EasyInvoice\Forms\FormProcessor();
865 $all_fields = $quote_form_manager->getAllFields();
866 $form_processor->saveFormDataToDatabase($quote_data['data'], $all_fields, $quote);
867
868 // Now update the quote with the processed data, passing the existing quote object
869 $quote = $repository->update($quote_id, $quote_data['data'], $quote);
870
871 if (!$quote) {
872 $this->sendError(__('Failed to update quote', 'easy-invoice'));
873 }
874
875 $message = __('Quote updated successfully', 'easy-invoice');
876 } else {
877 // Create new quote - allow auto-generated quote number to be saved
878 // The quote number will be auto-generated by the form and included in the data
879
880 $quote = $repository->create($quote_data['data']);
881
882 if (!$quote) {
883 $this->sendError(__('Failed to create quote', 'easy-invoice'));
884 }
885
886 // Use FormProcessor to save form data to database
887 $form_processor = new \EasyInvoice\Forms\FormProcessor();
888 $all_fields = $quote_form_manager->getAllFields();
889 $form_processor->saveFormDataToDatabase($quote_data['data'], $all_fields, $quote);
890
891 $quote_id = $quote->getId();
892 $message = __('Quote created successfully', 'easy-invoice');
893 }
894
895 // Handle items
896 if (isset($quote_data['data']['items']) && is_array($quote_data['data']['items'])) {
897 $quote->setItems($quote_data['data']['items']);
898 // Save the quote to persist the items to database
899 $quote->save();
900 }
901
902 $quote_template = get_post_meta($quote_id, '_easy_invoice_quote_quote_template', true);
903
904 $quote_template = $quote_template=='' ? 'standard': $quote_template;
905
906 update_option('easy_invoice_last_quote_template',$quote_template );
907 // Prepare response data
908 $response_data = array(
909 'quote_id' => $quote_id,
910 'quote' => $quote->toArray(),
911 'toast' => array(
912 'type' => 'success',
913 'message' => $message,
914 'options' => array('duration' => 4000)
915 )
916 );
917
918 // Include client data if quote has a client
919 if ($quote->getClientId()) {
920 $client_repository = ClientServiceProvider::getClientRepository();
921 $client = $client_repository->find($quote->getClientId());
922 if ($client) {
923 $response_data['client'] = array(
924 'id' => $client->getId(),
925 'name' => $client->getBusinessClientName() ?: ($client->getFirstName() . ' ' . $client->getLastName()),
926 'email' => $client->getEmail() ?: '',
927 'phone' => $client->getExtraInfo() ?: '',
928 'company' => $client->getBusinessClientName() ?: '',
929 'address' => $client->getAddress() ?: '',
930 'website' => $client->getWebsite() ?: '',
931 );
932 }
933 }
934
935
936 $this->sendSuccess($response_data);
937 }
938
939 /**
940 * Toggle a template as favorite
941 */
942
943
944
945
946 /**
947 * Check if an email already exists for any client
948 */
949 public function checkEmailExists() {
950 $this->verifyNonce('easy_invoice_nonce');
951
952 // Email-lookup is used during client creation; anyone who can manage
953 // clients can check duplicates.
954 if (!easy_invoice_user_can('ei_manage_clients')) {
955 $this->sendError(__('You do not have permission to perform this action', 'easy-invoice'));
956 }
957
958 $email = isset($_POST['email']) ? sanitize_email($_POST['email']) : '';
959
960 if (empty($email)) {
961 $this->sendSuccess(array('exists' => false));
962 }
963
964 $repository = ClientServiceProvider::getClientRepository();
965 $existing_clients = $repository->findByEmail($email);
966
967 $this->sendSuccess(array(
968 'exists' => !empty($existing_clients),
969 'count' => count($existing_clients)
970 ));
971 }
972
973 /**
974 * Generate a secure password.
975 */
976 public function generatePassword() {
977 $this->verifyNonce('easy_invoice_nonce');
978
979 // Used when creating a client (WP user); same gate as client management.
980 if (!easy_invoice_user_can('ei_manage_clients')) {
981 $this->sendError(__('You do not have permission to perform this action', 'easy-invoice'));
982 }
983
984 $password = wp_generate_password(16, true, true);
985
986 // Check if we should suppress global toast
987 $suppress_toast = isset($_POST['suppress_global_toast']) && $_POST['suppress_global_toast'] === 'true';
988
989 $this->sendSuccess(array(
990 'password' => $password,
991 'suppress_toast' => $suppress_toast
992 ));
993 }
994
995 /**
996 * Sanitize invoice items
997 *
998 * @param array $items Raw items data
999 * @return array Sanitized items data
1000 */
1001 private function sanitizeItems(array $items): array {
1002 $sanitized_items = [];
1003
1004 // Get field configuration for dynamic processing
1005 $form_manager = new \EasyInvoice\Forms\Invoice\InvoiceFormManager();
1006 $field_config = $form_manager->getItemFields();
1007
1008 foreach ($items as $item) {
1009 if (!is_array($item)) {
1010 continue;
1011 }
1012
1013 $sanitized_item = [];
1014
1015 // Process each field dynamically based on configuration
1016 foreach ($field_config as $field) {
1017 $field_name = $field['name'] ?? '';
1018 $field_type = $field['type'] ?? 'text';
1019 $raw_value = $item[$field_name] ?? '';
1020
1021 // Apply field-specific sanitization
1022 switch ($field_type) {
1023 case 'text':
1024 $sanitized_item[$field_name] = sanitize_text_field($raw_value);
1025 break;
1026 case 'textarea':
1027 $sanitized_item[$field_name] = wp_kses_post($raw_value);
1028 break;
1029 case 'number':
1030 $sanitized_item[$field_name] = is_numeric($raw_value) ? floatval($raw_value) : 0;
1031 break;
1032 case 'checkbox':
1033 $sanitized_item[$field_name] = !empty($raw_value) ? true : false;
1034 break;
1035 default:
1036 $sanitized_item[$field_name] = sanitize_text_field($raw_value);
1037 break;
1038 }
1039 }
1040
1041 // Handle legacy field names for backward compatibility
1042 if (isset($item['name']) && !isset($sanitized_item['title'])) {
1043 $sanitized_item['title'] = sanitize_text_field($item['name']);
1044 }
1045 if (isset($item['title']) && !isset($sanitized_item['title'])) {
1046 $sanitized_item['title'] = sanitize_text_field($item['title']);
1047 }
1048
1049 // Only add items that have at least a title/name
1050 if (!empty($sanitized_item['title'])) {
1051 $sanitized_items[] = $sanitized_item;
1052 }
1053 }
1054
1055 return $sanitized_items;
1056 }
1057
1058 /**
1059 * Add a new client (specifically for the client form in templates/clients-page.php)
1060 */
1061 public function addClient() {
1062
1063 $this->verifyNonce('easy_invoice_nonce');
1064
1065 if (!easy_invoice_user_can('ei_manage_clients')) {
1066 $this->sendError(__('You do not have permission to perform this action', 'easy-invoice'));
1067 }
1068
1069 // Check if required fields are present
1070 $required_fields = ['business_client_name', 'email', 'username'];
1071 foreach ($required_fields as $field) {
1072 if (!isset($_POST[$field]) || empty($_POST[$field])) {
1073 $this->sendError(__('Missing required field: ' . $field, 'easy-invoice'));
1074 }
1075 }
1076
1077 // Prepare client data
1078 $client_data = [
1079 ClientFields::BUSINESS_CLIENT_NAME => sanitize_text_field($_POST['business_client_name']),
1080 ClientFields::EMAIL => sanitize_email($_POST['email']),
1081 ClientFields::USERNAME => sanitize_user($_POST['username']),
1082 ClientFields::PASSWORD => $_POST['password'],
1083 ClientFields::ADDRESS => sanitize_textarea_field($_POST['address']),
1084 ClientFields::EXTRA_INFO => sanitize_textarea_field($_POST['extra_info']),
1085 ClientFields::FIRST_NAME => sanitize_text_field($_POST['first_name']),
1086 ClientFields::LAST_NAME => sanitize_text_field($_POST['last_name']),
1087 ClientFields::WEBSITE => esc_url_raw($_POST['website']),
1088 ClientFields::PHONE => isset($_POST['phone']) ? sanitize_text_field($_POST['phone']) : '',
1089 ];
1090
1091
1092
1093 // Basic validation
1094 if (empty($client_data[ClientFields::BUSINESS_CLIENT_NAME]) && (empty($client_data[ClientFields::FIRST_NAME]) || empty($client_data[ClientFields::LAST_NAME]))) {
1095 $this->sendError(__('Please provide a client name or first/last name.', 'easy-invoice'));
1096 }
1097
1098 if (empty($client_data[ClientFields::EMAIL])) {
1099 $this->sendError(__('Email address is required', 'easy-invoice'));
1100 }
1101
1102 $repository = ClientServiceProvider::getClientRepository();
1103
1104 // Create new client
1105 $client = $repository->create($client_data);
1106
1107 if (!$client) {
1108 $this->sendError(__('Failed to create client', 'easy-invoice'));
1109 }
1110
1111 $client_id = $client->getId();
1112
1113 // Pull the WP role assigned during user creation. The
1114 // Clients-page row template needs this so the new-row badge
1115 // matches the role that will be re-rendered server-side on the
1116 // next page load. Without this, the JS template would have to
1117 // hardcode a role label and could drift from PHP's value.
1118 $user = get_user_by('id', $client_id);
1119 $role = ($user && !empty($user->roles)) ? (string) $user->roles[0] : 'customer';
1120
1121 $response_data = array(
1122 'message' => __('Client added successfully', 'easy-invoice'),
1123 'client_id' => $client_id,
1124 'role' => $role,
1125 'role_label' => ucfirst($role),
1126 'client' => $client->toArray(),
1127 );
1128
1129 $this->sendSuccess($response_data);
1130 }
1131
1132 /**
1133 * Update client from the client edit form
1134 */
1135 public function updateClient() {
1136 $this->verifyNonce('easy_invoice_nonce');
1137
1138 if (!easy_invoice_user_can('ei_manage_clients')) {
1139 $this->sendError(__('You do not have permission to perform this action', 'easy-invoice'));
1140 }
1141
1142 $client_id = isset($_POST['client_id']) ? intval($_POST['client_id']) : 0;
1143
1144 if ($client_id <= 0) {
1145 $this->sendError(__('Invalid client ID', 'easy-invoice'));
1146 }
1147
1148 // Prepare client data
1149 $client_data = [
1150 ClientFields::BUSINESS_CLIENT_NAME => sanitize_text_field($_POST['business_client_name']),
1151 ClientFields::EMAIL => sanitize_email($_POST['email']),
1152 ClientFields::USERNAME => sanitize_user($_POST['username']),
1153 ClientFields::PASSWORD => $_POST['password'], // Keep password as is, don't sanitize
1154 ClientFields::ADDRESS => sanitize_textarea_field($_POST['address']),
1155 ClientFields::PHONE => isset($_POST['phone']) ? sanitize_text_field($_POST['phone']) : '',
1156 ClientFields::EXTRA_INFO => sanitize_textarea_field($_POST['extra_info']),
1157 ClientFields::FIRST_NAME => sanitize_text_field($_POST['first_name']),
1158 ClientFields::LAST_NAME => sanitize_text_field($_POST['last_name']),
1159 ClientFields::WEBSITE => esc_url_raw($_POST['website'])
1160 ];
1161
1162 // Remove empty values except password (password can be empty for updates)
1163 $client_data = array_filter($client_data, function($value, $key) {
1164 if ($key === ClientFields::PASSWORD) {
1165 return true; // Always include password field
1166 }
1167 return $value !== '';
1168 }, ARRAY_FILTER_USE_BOTH);
1169
1170 if (empty($client_data)) {
1171 $this->sendError(__('No data provided to update.', 'easy-invoice'));
1172 }
1173
1174 $repository = ClientServiceProvider::getClientRepository();
1175
1176 // Update existing client
1177 $client = $repository->update($client_id, $client_data);
1178
1179 if (!$client) {
1180 $this->sendError(__('Failed to update client', 'easy-invoice'));
1181 }
1182
1183 $this->sendSuccess(array(
1184 'message' => __('Client updated successfully', 'easy-invoice'),
1185 'client_id' => $client_id,
1186 'client' => $client->toArray(),
1187 ));
1188 }
1189
1190 /**
1191 * Update invoices data with missing client information and totals
1192 */
1193 public function updateInvoicesData() {
1194 $this->verifyNonce('easy_invoice_admin_nonce');
1195
1196 // Bulk migration / repair of invoice records — admin-only.
1197 if (!current_user_can('manage_options')) {
1198 $this->sendError(__('You do not have permission to perform this action', 'easy-invoice'));
1199 }
1200
1201 $repository = InvoiceServiceProvider::getInvoiceRepository();
1202 $client_repository = ClientServiceProvider::getClientRepository();
1203
1204 // Get all invoices
1205 $invoices = $repository->all();
1206 $updated_count = 0;
1207
1208 foreach ($invoices as $invoice) {
1209 $updated = false;
1210
1211 // Check if client data is missing
1212 $client_id = $invoice->getClientId();
1213 if ($client_id > 0) {
1214 $client = $client_repository->find($client_id);
1215 if ($client) {
1216 // Update customer name if missing
1217 $customer_name = $invoice->getCustomerName();
1218 if (empty($customer_name)) {
1219 $customer_name = $client->getBusinessClientName() ?: ($client->getFirstName() . ' ' . $client->getLastName());
1220 $invoice->setCustomerName($customer_name);
1221 $updated = true;
1222 }
1223
1224 // Update customer email if missing
1225 $customer_email = $invoice->getCustomerEmail();
1226 if (empty($customer_email)) {
1227 $customer_email = $client->getEmail();
1228 $invoice->setCustomerEmail($customer_email);
1229 $updated = true;
1230 }
1231
1232 // Update customer address if missing
1233 $customer_address = $invoice->getCustomerAddress();
1234 if (empty($customer_address)) {
1235 $customer_address = $client->getAddress();
1236 $invoice->setCustomerAddress($customer_address);
1237 $updated = true;
1238 }
1239 }
1240 }
1241
1242 // Check if total is missing or zero
1243 $total = $invoice->getTotal();
1244 if (empty($total) || $total == 0) {
1245 // Recalculate total from items
1246 $items = $invoice->getItems();
1247 if (!empty($items)) {
1248 $subtotal = 0;
1249 foreach ($items as $item) {
1250 if (method_exists($item, 'getAmount')) {
1251 $subtotal += $item->getAmount();
1252 } elseif (isset($item['amount'])) {
1253 $subtotal += $item['amount'];
1254 }
1255 }
1256
1257 // Calculate discount and tax
1258 $discount = $invoice->getDiscountAmount();
1259 $tax = $invoice->getTaxAmount();
1260
1261 $total = $subtotal - $discount + $tax;
1262
1263 // Save the calculated total
1264 $invoice->setMeta('_easy_invoice_total', $total);
1265 $updated = true;
1266 }
1267 }
1268
1269 if ($updated) {
1270 $updated_count++;
1271 }
1272 }
1273
1274 $this->sendSuccess(array(
1275 'message' => sprintf(__('Updated %d invoices with missing data', 'easy-invoice'), $updated_count),
1276 'updated_count' => $updated_count
1277 ));
1278 }
1279
1280 /**
1281 * Download invoice as PDF (public access)
1282 */
1283 public function downloadInvoicePdf() {
1284 // Verify nonce
1285 $this->verifyNonce('easy_invoice_nonce');
1286
1287 // Get invoice ID
1288 $invoice_id = isset($_POST['invoice_id']) ? intval($_POST['invoice_id']) : 0;
1289
1290 if (!$invoice_id) {
1291 $this->sendError(__('Invalid invoice ID', 'easy-invoice'));
1292 }
1293
1294 // Get invoice from repository (only published invoices for public access)
1295 $repository = InvoiceServiceProvider::getInvoiceRepository();
1296
1297 // For admins, allow access to any invoice status
1298 if (current_user_can('manage_options')) {
1299 $invoice = $repository->find($invoice_id);
1300 } else {
1301 // For non-admins, only allow access to published invoices
1302 $invoice = $repository->findPublished($invoice_id);
1303 }
1304
1305 if (!$invoice) {
1306 $this->sendError(__('Invoice not found', 'easy-invoice'));
1307 }
1308
1309 // Get invoice data for PDF generation
1310 $invoice_data = \EasyInvoice\Includes\Helpers\PdfHelper::getInvoiceDataForPdf($invoice);
1311
1312 // For now, return success response with invoice data
1313 // PDF generation can be implemented later with actual PDF creation
1314 $this->sendSuccess(array(
1315 'message' => __('Invoice data retrieved successfully', 'easy-invoice'),
1316 'invoice_data' => $invoice_data,
1317 'download_url' => add_query_arg(array(
1318 'action' => 'easy_invoice_generate_pdf',
1319 'invoice_id' => $invoice_id,
1320 'nonce' => wp_create_nonce('generate_pdf')
1321 ), admin_url('admin-ajax.php'))
1322 ));
1323 }
1324
1325 /**
1326 * Send invoice via email (public access)
1327 */
1328 public function sendInvoiceEmailPublic() {
1329 $this->verifyNonce('easy_invoice_send_invoice_email');
1330
1331 // Get invoice ID
1332 $invoice_id = isset($_POST['invoice_id']) ? intval($_POST['invoice_id']) : 0;
1333
1334 if (!$invoice_id) {
1335 $this->sendError(__('Invalid invoice ID', 'easy-invoice'));
1336 }
1337
1338 // Get invoice from repository (only published invoices for public access)
1339 $repository = InvoiceServiceProvider::getInvoiceRepository();
1340
1341 // For admins, allow access to any invoice status
1342 if (current_user_can('manage_options')) {
1343 $invoice = $repository->find($invoice_id);
1344 } else {
1345 // For non-admins, only allow access to published invoices
1346 $invoice = $repository->findPublished($invoice_id);
1347 }
1348
1349 if (!$invoice) {
1350 $this->sendError(__('Invoice not found', 'easy-invoice'));
1351 }
1352
1353 // Use EmailManager to send the email
1354 $email_manager = \EasyInvoice\Services\EmailManager::getInstance();
1355 $result = $email_manager->sendInvoiceEmail($invoice, 'new');
1356
1357 if ($result['success']) {
1358 $this->sendSuccess(array(
1359 'message' => $result['message']
1360 ));
1361 } else {
1362 $this->sendError($result['message']);
1363 }
1364 }
1365
1366 /**
1367 * Send quote via email (admin + public; guests only for published quotes).
1368 */
1369 public function sendQuoteEmailPublic() {
1370 $this->verifyNonce('easy_invoice_send_quote_email');
1371
1372 $quote_id = isset($_POST['quote_id']) ? intval($_POST['quote_id']) : 0;
1373
1374 if (!$quote_id) {
1375 $this->sendError(__('Invalid quote ID', 'easy-invoice'));
1376 }
1377
1378 $repository = \EasyInvoice\Providers\QuoteServiceProvider::getQuoteRepository();
1379
1380 if (current_user_can('manage_options')) {
1381 $quote = $repository->find($quote_id);
1382 } else {
1383 $quote = $repository->findPublished($quote_id);
1384 }
1385
1386 if (!$quote) {
1387 $this->sendError(__('Quote not found', 'easy-invoice'));
1388 }
1389
1390 $email_manager = \EasyInvoice\Services\EmailManager::getInstance();
1391 $result = $email_manager->sendQuoteEmail($quote, 'new');
1392
1393 if ($result['success']) {
1394 $quote_log_service = new \EasyInvoice\Services\QuoteLogService();
1395 $quote_log_service->logSent($quote_id, $quote->getCustomerEmail());
1396
1397 $this->sendSuccess(array(
1398 'message' => $result['message'],
1399 ));
1400 } else {
1401 $this->sendError($result['message']);
1402 }
1403 }
1404
1405 /**
1406 * Generate invoice PDF
1407 */
1408 public function generateInvoicePdf() {
1409 // Verify nonce
1410 $this->verifyNonce('generate_pdf');
1411
1412 // Get invoice ID
1413 $invoice_id = isset($_REQUEST['invoice_id']) ? intval($_REQUEST['invoice_id']) : 0;
1414
1415 if (!$invoice_id) {
1416 $this->sendError(__('Invalid invoice ID', 'easy-invoice'));
1417 }
1418
1419 // Get invoice from repository
1420 $repository = InvoiceServiceProvider::getInvoiceRepository();
1421
1422 // For admins, allow access to any invoice status
1423 if (current_user_can('manage_options')) {
1424 $invoice = $repository->find($invoice_id);
1425 } else {
1426 // For non-admins, only allow access to published invoices
1427 $invoice = $repository->findPublished($invoice_id);
1428 }
1429
1430 if (!$invoice) {
1431 $this->sendError(__('Invoice not found', 'easy-invoice'));
1432 }
1433
1434 // Redirect to the invoice single page with PDF generation
1435 $invoice_url = get_permalink($invoice_id);
1436 if ($invoice_url) {
1437 wp_redirect(add_query_arg('auto_download_pdf', '1', $invoice_url));
1438 exit;
1439 } else {
1440 $this->sendError(__('Could not generate invoice URL', 'easy-invoice'));
1441 }
1442 }
1443
1444 /**
1445 * Generate quote PDF
1446 */
1447 public function generateQuotePdf() {
1448 // Verify nonce
1449 $this->verifyNonce('generate_quote_pdf');
1450
1451 // Get quote ID
1452 $quote_id = isset($_REQUEST['quote_id']) ? intval($_REQUEST['quote_id']) : 0;
1453
1454 if (!$quote_id) {
1455 $this->sendError(__('Invalid quote ID', 'easy-invoice'));
1456 }
1457
1458 // Get quote from repository — mirror invoice PDF: only published quotes for non-admins (incl. nopriv).
1459 $repository = \EasyInvoice\Providers\QuoteServiceProvider::getQuoteRepository();
1460 if (current_user_can('manage_options')) {
1461 $quote = $repository->find($quote_id);
1462 } else {
1463 $quote = $repository->findPublished($quote_id);
1464 }
1465
1466 if (!$quote) {
1467 $this->sendError(__('Quote not found', 'easy-invoice'));
1468 }
1469
1470 // Redirect to the quote single page with PDF generation
1471 $quote_url = get_permalink($quote_id);
1472 if ($quote_url) {
1473 wp_redirect(add_query_arg('auto_download_pdf', '1', $quote_url));
1474 exit;
1475 } else {
1476 $this->sendError(__('Could not generate quote URL', 'easy-invoice'));
1477 }
1478 }
1479
1480 /**
1481 * Search clients for the dropdown
1482 */
1483 public function searchClients() {
1484 $this->verifyNonce('easy_invoice_nonce');
1485
1486 if (!easy_invoice_user_can('ei_view_clients')) {
1487 $this->sendError(__('You do not have permission to perform this action', 'easy-invoice'));
1488 }
1489
1490 $query = isset($_POST['query']) ? sanitize_text_field($_POST['query']) : '';
1491
1492 // Get client repository
1493 $client_repository = \EasyInvoice\Providers\ClientServiceProvider::getClientRepository();
1494
1495 // Search clients
1496 $clients = $client_repository->search($query);
1497
1498 // Row-level security: restrict to assigned clients for Sales reps
1499 // (users with ei_view_clients but no ei_view_all_clients). Null
1500 // return = unrestricted, no-op.
1501 if (function_exists('easy_invoice_visible_client_ids')) {
1502 $visible = easy_invoice_visible_client_ids();
1503 if (is_array($visible)) {
1504 $allowed = array_flip(array_map('intval', $visible));
1505 $clients = array_values(array_filter($clients, static function ($c) use ($allowed) {
1506 return isset($allowed[(int) $c->getId()]);
1507 }));
1508 }
1509 }
1510
1511 // Bypass $this->sendSuccess() — search is a read endpoint and
1512 // shouldn't show "Operation completed successfully" toasts on
1513 // every keystroke. Use wp_send_json_success directly.
1514 if (empty($clients)) {
1515 wp_send_json_success(array());
1516 }
1517
1518 // Format clients for dropdown
1519 $formatted_clients = array();
1520 foreach ($clients as $client) {
1521 // Get the WordPress user data directly
1522 $user = get_user_by('id', $client->getId());
1523 if (!$user) {
1524 continue;
1525 }
1526
1527 // Use Client model properties first, fallback to WordPress user fields
1528 $business_name = $client->business_client_name ?: '';
1529 $first_name = $client->first_name ?: $user->first_name ?: '';
1530 $last_name = $client->last_name ?: $user->last_name ?: '';
1531 $email = $client->email ?: $user->user_email ?: '';
1532
1533
1534
1535 // Create display name
1536 $client_name = $business_name ?: ($first_name . ' ' . $last_name);
1537 if (empty(trim($client_name))) {
1538 $client_name = $user->display_name ?: 'User ' . $client->getId();
1539 }
1540
1541 // Include all clients, even those with empty emails
1542 $formatted_clients[] = array(
1543 'id' => $client->getId(),
1544 'name' => $client_name,
1545 'email' => $email,
1546 'company' => $business_name,
1547 'phone' => $client->phone ?: '',
1548 'address' => $client->address ?: '',
1549 'website' => $client->website ?: '',
1550 'display_name' => $client_name . ' (' . $email . ')'
1551 );
1552 }
1553
1554 wp_send_json_success($formatted_clients);
1555 }
1556
1557 /**
1558 * Save additional CSS for invoice/quote
1559 */
1560 public function saveAdditionalCSS() {
1561 // Verify nonce
1562 if (!wp_verify_nonce($_POST['nonce'], 'save_additional_css_nonce')) {
1563 $this->sendError('Security check failed');
1564 return;
1565 }
1566
1567 // Check user capabilities - require administrator
1568 if (!current_user_can('manage_options')) {
1569 $this->sendError('You do not have permission to perform this action');
1570 return;
1571 }
1572
1573 // Validate and sanitize post ID
1574 $post_id = isset($_POST['post_id']) ? intval($_POST['post_id']) : 0;
1575 if ($post_id <= 0) {
1576 $this->sendError('Invalid post ID');
1577 return;
1578 }
1579
1580 // Verify post exists and user can edit it
1581 $post = get_post($post_id);
1582 if (!$post || !current_user_can('edit_post', $post_id)) {
1583 $this->sendError('You cannot edit this post');
1584 return;
1585 }
1586
1587 // Verify post type is invoice or quote
1588 $valid_post_types = [
1589 \EasyInvoice\Constants\PostTypes::EASY_INVOICE_POST_TYPE,
1590 \EasyInvoice\Constants\PostTypes::EASY_INVOICE_QUOTE_POST_TYPE
1591 ];
1592 if (!in_array($post->post_type, $valid_post_types)) {
1593 $this->sendError('Invalid post type');
1594 return;
1595 }
1596
1597 // Get and sanitize CSS content
1598 $css = isset($_POST['css']) ? $_POST['css'] : '';
1599
1600 // Enhanced CSS sanitization
1601 $css = $this->sanitizeCSS($css);
1602
1603 // Limit CSS length to prevent abuse
1604 if (strlen($css) > 50000) { // 50KB limit
1605 $this->sendError('CSS content too long');
1606 return;
1607 }
1608
1609 // Save CSS to post meta
1610 $result = update_post_meta($post_id, '_easy_invoice_additional_css', $css);
1611
1612 if ($result !== false) {
1613 $this->sendSuccess(array(
1614 'message' => 'CSS saved successfully',
1615 'css' => $css,
1616 'post_id' => $post_id
1617 ));
1618 } else {
1619 $this->sendError('Failed to save CSS');
1620 }
1621 }
1622
1623 /**
1624 * Enhanced CSS sanitization - preserves valid CSS while removing threats
1625 */
1626 private function sanitizeCSS($css) {
1627 // Remove PHP tags first
1628 $css = preg_replace('/<\?php.*?\?>/is', '', $css);
1629
1630 // Remove HTML tags (script, iframe, object, embed)
1631 $css = preg_replace('/<script[^>]*>.*?<\/script>/is', '', $css);
1632 $css = preg_replace('/<iframe[^>]*>.*?<\/iframe>/is', '', $css);
1633 $css = preg_replace('/<object[^>]*>.*?<\/object>/is', '', $css);
1634 $css = preg_replace('/<embed[^>]*>/is', '', $css);
1635
1636 // Remove dangerous CSS constructs
1637 $css = preg_replace('/expression\s*\(/i', '', $css); // CSS expressions
1638 $css = preg_replace('/javascript\s*:/i', '', $css); // JavaScript protocol
1639 $css = preg_replace('/@import\s+url\s*\(/i', '', $css); // @import url()
1640 $css = preg_replace('/@import\s+["\'][^"\']+["\']/', '', $css); // @import with quotes
1641 $css = preg_replace('/behavior\s*:\s*url\s*\(/i', '', $css); // IE behavior
1642 $css = preg_replace('/binding\s*:/i', '', $css); // XBL binding
1643
1644 // Remove dangerous CSS functions (but keep safe ones)
1645 $dangerous_functions = ['eval', 'exec', 'system', 'passthru', 'shell_exec', 'phpinfo', 'file_get_contents', 'file_put_contents', 'fopen', 'fwrite', 'curl_exec'];
1646 foreach ($dangerous_functions as $func) {
1647 $css = preg_replace('/\b' . preg_quote($func, '/') . '\s*\(/i', '', $css);
1648 }
1649
1650 // Remove data URLs that could contain malicious content
1651 $css = preg_replace('/data\s*:\s*["\'][^"\']*["\']/i', '', $css);
1652
1653 // Remove vbscript: protocol
1654 $css = preg_replace('/vbscript\s*:/i', '', $css);
1655
1656 // Remove any remaining HTML-like constructs
1657 $css = htmlspecialchars_decode($css, ENT_QUOTES);
1658
1659 // Basic cleanup - remove excessive whitespace but preserve CSS structure
1660 $css = preg_replace('/\s+/', ' ', $css);
1661 $css = preg_replace('/;\s*}/', '}', $css);
1662 $css = preg_replace('/\s*{\s*/', ' {', $css);
1663 $css = preg_replace('/;\s*;/', ';', $css);
1664
1665 return trim($css);
1666 }
1667 }
1668