PluginProbe
Easy Invoice – Invoice Generator, PDF Quotes & Payments / 2.3.3
Easy Invoice – Invoice Generator, PDF Quotes & Payments v2.3.3
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.3, at includes/Admin/EasyInvoiceAjax.php

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