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

1,828 lines 71.0 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 // Explicitly bust intermediate caching on this admin-ajax URL. Some
1420 // page-caching stacks (WP Rocket, LiteSpeed, Cloudflare full-page
1421 // cache, some CDNs) will cache a 302 Location header keyed by URL —
1422 // the URL always looks the same to the cache because both the nonce
1423 // AND the target invoice-permalink change per user, so a first-hit
1424 // response can be replayed to other users, breaking the redirect or
1425 // returning a blank body.
1426 nocache_headers();
1427 header('Cache-Control: no-store, no-cache, must-revalidate, max-age=0');
1428 header('Pragma: no-cache');
1429
1430 // Get invoice ID
1431 $invoice_id = isset($_REQUEST['invoice_id']) ? intval($_REQUEST['invoice_id']) : 0;
1432
1433 if (!$invoice_id) {
1434 $this->sendError(__('Invalid invoice ID', 'easy-invoice'));
1435 }
1436
1437 // Authorisation with graceful fallback.
1438 //
1439 // The original design gated this endpoint on a per-request WP
1440 // nonce, which is fragile in real deployments: page-caching
1441 // layers (WP Rocket, LiteSpeed, Cloudflare full-page cache)
1442 // cache the intermediate JSON response that mints the URL,
1443 // browser SameSite / ITP behaviour, and admin_url()
1444 // scheme-mismatch after login can all cause wp_verify_nonce()
1445 // to return false on the intended recipient's tab — leaving
1446 // the user stranded on this admin-ajax URL with no download.
1447 //
1448 // Accept ANY of:
1449 // 1. A valid `generate_pdf` nonce (fast path — most users,
1450 // most of the time, when the session cookie survives).
1451 // 2. An admin session (manage_options) — bypasses the nonce
1452 // because the invoice-listing button that creates this
1453 // URL is admin-only and the admin owns the request.
1454 // 3. A valid per-invoice access token (?ik=<token>) — the
1455 // same model canSubmitPaymentForInvoice uses, so emailed
1456 // invoice links can also drive a session-less download.
1457 // Only when all three paths fail do we refuse.
1458 $authorized = false;
1459
1460 // Match the same dual-key nonce lookup the removed verifyNonce()
1461 // helper did — `nonce` (client-form format used by our JS) AND
1462 // `_nonce` (standard WP form field name) — so any external
1463 // caller of this endpoint that used _nonce still works.
1464 $submitted_nonce = '';
1465 if (isset($_REQUEST['nonce'])) {
1466 $submitted_nonce = (string) $_REQUEST['nonce'];
1467 } elseif (isset($_REQUEST['_nonce'])) {
1468 $submitted_nonce = (string) $_REQUEST['_nonce'];
1469 }
1470 if ($submitted_nonce !== '' && wp_verify_nonce($submitted_nonce, 'generate_pdf')) {
1471 $authorized = true;
1472 } elseif (current_user_can('manage_options')) {
1473 $authorized = true;
1474 } elseif (isset($_REQUEST['ik']) && is_string($_REQUEST['ik'])) {
1475 $presented = sanitize_text_field(wp_unslash($_REQUEST['ik']));
1476 $stored = (string) get_post_meta($invoice_id, '_easy_invoice_invoice_access_token', true);
1477 if ($stored !== '' && $presented !== '' && hash_equals($stored, $presented)) {
1478 $authorized = true;
1479 }
1480 }
1481
1482 if (!$authorized) {
1483 $this->sendError(__('Security check failed', 'easy-invoice'));
1484 }
1485
1486 // Get invoice from repository
1487 $repository = InvoiceServiceProvider::getInvoiceRepository();
1488
1489 // For admins, allow access to any invoice status
1490 if (current_user_can('manage_options')) {
1491 $invoice = $repository->find($invoice_id);
1492 } else {
1493 // For non-admins, only allow access to published invoices
1494 $invoice = $repository->findPublished($invoice_id);
1495 }
1496
1497 if (!$invoice) {
1498 $this->sendError(__('Invoice not found', 'easy-invoice'));
1499 }
1500
1501 // Redirect to the invoice single page with PDF generation.
1502 // Forward the ?ik= access token onwards so the single-page
1503 // template can also authorise the recipient (the same token
1504 // that got us through Path 3 above).
1505 $invoice_url = get_permalink($invoice_id);
1506 if (!$invoice_url) {
1507 $this->sendError(__('Could not generate invoice URL', 'easy-invoice'));
1508 }
1509
1510 $target_args = ['auto_download_pdf' => '1'];
1511 if (isset($_REQUEST['ik']) && is_string($_REQUEST['ik']) && $_REQUEST['ik'] !== '') {
1512 $target_args['ik'] = sanitize_text_field(wp_unslash($_REQUEST['ik']));
1513 }
1514 $target_url = add_query_arg($target_args, $invoice_url);
1515
1516 $this->redirectWithFallback($target_url);
1517 }
1518
1519 /**
1520 * Generate quote PDF
1521 */
1522 public function generateQuotePdf() {
1523 // Same cache-busting as generateInvoicePdf — see comment there.
1524 nocache_headers();
1525 header('Cache-Control: no-store, no-cache, must-revalidate, max-age=0');
1526 header('Pragma: no-cache');
1527
1528 // Get quote ID
1529 $quote_id = isset($_REQUEST['quote_id']) ? intval($_REQUEST['quote_id']) : 0;
1530
1531 if (!$quote_id) {
1532 $this->sendError(__('Invalid quote ID', 'easy-invoice'));
1533 }
1534
1535 // Authorisation with graceful fallback. Same three-path model
1536 // as generateInvoicePdf — see that method's comment for the
1537 // full rationale (nonce fragility across caching layers,
1538 // cross-tab session cookie behaviour, etc.). Paths accepted:
1539 //
1540 // 1. Valid `generate_quote_pdf` nonce (fast path).
1541 // 2. Admin session (manage_options) — the quote-listing
1542 // button that mints this URL is admin-only.
1543 // 3. Valid per-quote access token (?qk=<token>) — mirrors
1544 // the CVE-2026-9021 model so emailed quote links can
1545 // drive a session-less PDF download.
1546 $authorized = false;
1547
1548 // Same dual-key nonce lookup as the invoice handler — see
1549 // generateInvoicePdf for the backward-compat rationale.
1550 $submitted_nonce = '';
1551 if (isset($_REQUEST['nonce'])) {
1552 $submitted_nonce = (string) $_REQUEST['nonce'];
1553 } elseif (isset($_REQUEST['_nonce'])) {
1554 $submitted_nonce = (string) $_REQUEST['_nonce'];
1555 }
1556 if ($submitted_nonce !== '' && wp_verify_nonce($submitted_nonce, 'generate_quote_pdf')) {
1557 $authorized = true;
1558 } elseif (current_user_can('manage_options')) {
1559 $authorized = true;
1560 } elseif (isset($_REQUEST['qk']) && is_string($_REQUEST['qk'])) {
1561 $presented = sanitize_text_field(wp_unslash($_REQUEST['qk']));
1562 $stored = (string) get_post_meta($quote_id, '_easy_invoice_quote_access_token', true);
1563 if ($stored !== '' && $presented !== '' && hash_equals($stored, $presented)) {
1564 $authorized = true;
1565 }
1566 }
1567
1568 if (!$authorized) {
1569 $this->sendError(__('Security check failed', 'easy-invoice'));
1570 }
1571
1572 // Get quote from repository — mirror invoice PDF: only published quotes for non-admins (incl. nopriv).
1573 $repository = \EasyInvoice\Providers\QuoteServiceProvider::getQuoteRepository();
1574 if (current_user_can('manage_options')) {
1575 $quote = $repository->find($quote_id);
1576 } else {
1577 $quote = $repository->findPublished($quote_id);
1578 }
1579
1580 if (!$quote) {
1581 $this->sendError(__('Quote not found', 'easy-invoice'));
1582 }
1583
1584 // Redirect to the quote single page with PDF generation.
1585 // Forward the ?qk= access token so the single-page template
1586 // can also authorise the recipient with the same key that
1587 // got us through Path 3.
1588 $quote_url = get_permalink($quote_id);
1589 if (!$quote_url) {
1590 $this->sendError(__('Could not generate quote URL', 'easy-invoice'));
1591 }
1592
1593 $target_args = ['auto_download_pdf' => '1'];
1594 if (isset($_REQUEST['qk']) && is_string($_REQUEST['qk']) && $_REQUEST['qk'] !== '') {
1595 $target_args['qk'] = sanitize_text_field(wp_unslash($_REQUEST['qk']));
1596 }
1597 $target_url = add_query_arg($target_args, $quote_url);
1598
1599 $this->redirectWithFallback($target_url);
1600 }
1601
1602 /**
1603 * Redirect the current request to $url, with a client-side fallback
1604 * when the server-side redirect can't fire.
1605 *
1606 * `wp_redirect()` silently no-ops if headers have already been sent
1607 * (BOM in a plugin file, plugin echoing during an action, PHP warning
1608 * output, etc.). Because we also `exit;` immediately after, that failure
1609 * mode produces a 200 OK with an empty body — the reported blank-page
1610 * bug on the invoice-listing PDF download.
1611 *
1612 * This helper detects the headers-sent case and emits a minimal HTML
1613 * document that redirects via meta-refresh (works with JS disabled) and
1614 * `window.location.replace()` (JS-enabled, doesn't add a history entry).
1615 * Both point at the same escaped URL so misconfigured stacks still get
1616 * the user to the target page.
1617 */
1618 private function redirectWithFallback(string $url): void {
1619 // Suppress cache one more time in case some plugin filtered our
1620 // earlier headers away between then and now.
1621 nocache_headers();
1622
1623 if (!headers_sent()) {
1624 wp_redirect($url);
1625 exit;
1626 }
1627
1628 // Fallback: server-side redirect impossible. Emit a client-side one.
1629 $safe_url = esc_url_raw($url);
1630 echo '<!DOCTYPE html><html lang="en"><head><meta charset="UTF-8">';
1631 echo '<meta http-equiv="refresh" content="0; url=' . esc_attr($safe_url) . '">';
1632 echo '<title>Redirecting…</title>';
1633 echo '<script>window.location.replace(' . wp_json_encode($safe_url) . ');</script>';
1634 echo '</head><body>';
1635 echo '<p>Redirecting to <a href="' . esc_url($safe_url) . '">' . esc_html($safe_url) . '</a>…</p>';
1636 echo '</body></html>';
1637 exit;
1638 }
1639
1640 /**
1641 * Search clients for the dropdown
1642 */
1643 public function searchClients() {
1644 $this->verifyNonce('easy_invoice_nonce');
1645
1646 if (!easy_invoice_user_can('ei_view_clients')) {
1647 $this->sendError(__('You do not have permission to perform this action', 'easy-invoice'));
1648 }
1649
1650 $query = isset($_POST['query']) ? sanitize_text_field($_POST['query']) : '';
1651
1652 // Get client repository
1653 $client_repository = \EasyInvoice\Providers\ClientServiceProvider::getClientRepository();
1654
1655 // Search clients
1656 $clients = $client_repository->search($query);
1657
1658 // Row-level security: restrict to assigned clients for Sales reps
1659 // (users with ei_view_clients but no ei_view_all_clients). Null
1660 // return = unrestricted, no-op.
1661 if (function_exists('easy_invoice_visible_client_ids')) {
1662 $visible = easy_invoice_visible_client_ids();
1663 if (is_array($visible)) {
1664 $allowed = array_flip(array_map('intval', $visible));
1665 $clients = array_values(array_filter($clients, static function ($c) use ($allowed) {
1666 return isset($allowed[(int) $c->getId()]);
1667 }));
1668 }
1669 }
1670
1671 // Bypass $this->sendSuccess() — search is a read endpoint and
1672 // shouldn't show "Operation completed successfully" toasts on
1673 // every keystroke. Use wp_send_json_success directly.
1674 if (empty($clients)) {
1675 wp_send_json_success(array());
1676 }
1677
1678 // Format clients for dropdown
1679 $formatted_clients = array();
1680 foreach ($clients as $client) {
1681 // Get the WordPress user data directly
1682 $user = get_user_by('id', $client->getId());
1683 if (!$user) {
1684 continue;
1685 }
1686
1687 // Use Client model properties first, fallback to WordPress user fields
1688 $business_name = $client->business_client_name ?: '';
1689 $first_name = $client->first_name ?: $user->first_name ?: '';
1690 $last_name = $client->last_name ?: $user->last_name ?: '';
1691 $email = $client->email ?: $user->user_email ?: '';
1692
1693
1694
1695 // Create display name
1696 $client_name = $business_name ?: ($first_name . ' ' . $last_name);
1697 if (empty(trim($client_name))) {
1698 $client_name = $user->display_name ?: 'User ' . $client->getId();
1699 }
1700
1701 // Include all clients, even those with empty emails
1702 $formatted_clients[] = array(
1703 'id' => $client->getId(),
1704 'name' => $client_name,
1705 'email' => $email,
1706 'company' => $business_name,
1707 'phone' => $client->phone ?: '',
1708 'address' => $client->address ?: '',
1709 'website' => $client->website ?: '',
1710 'display_name' => $client_name . ' (' . $email . ')'
1711 );
1712 }
1713
1714 wp_send_json_success($formatted_clients);
1715 }
1716
1717 /**
1718 * Save additional CSS for invoice/quote
1719 */
1720 public function saveAdditionalCSS() {
1721 // Verify nonce
1722 if (!wp_verify_nonce($_POST['nonce'], 'save_additional_css_nonce')) {
1723 $this->sendError('Security check failed');
1724 return;
1725 }
1726
1727 // Check user capabilities - require administrator
1728 if (!current_user_can('manage_options')) {
1729 $this->sendError('You do not have permission to perform this action');
1730 return;
1731 }
1732
1733 // Validate and sanitize post ID
1734 $post_id = isset($_POST['post_id']) ? intval($_POST['post_id']) : 0;
1735 if ($post_id <= 0) {
1736 $this->sendError('Invalid post ID');
1737 return;
1738 }
1739
1740 // Verify post exists and user can edit it
1741 $post = get_post($post_id);
1742 if (!$post || !current_user_can('edit_post', $post_id)) {
1743 $this->sendError('You cannot edit this post');
1744 return;
1745 }
1746
1747 // Verify post type is invoice or quote
1748 $valid_post_types = [
1749 \EasyInvoice\Constants\PostTypes::EASY_INVOICE_POST_TYPE,
1750 \EasyInvoice\Constants\PostTypes::EASY_INVOICE_QUOTE_POST_TYPE
1751 ];
1752 if (!in_array($post->post_type, $valid_post_types)) {
1753 $this->sendError('Invalid post type');
1754 return;
1755 }
1756
1757 // Get and sanitize CSS content
1758 $css = isset($_POST['css']) ? $_POST['css'] : '';
1759
1760 // Enhanced CSS sanitization
1761 $css = $this->sanitizeCSS($css);
1762
1763 // Limit CSS length to prevent abuse
1764 if (strlen($css) > 50000) { // 50KB limit
1765 $this->sendError('CSS content too long');
1766 return;
1767 }
1768
1769 // Save CSS to post meta
1770 $result = update_post_meta($post_id, '_easy_invoice_additional_css', $css);
1771
1772 if ($result !== false) {
1773 $this->sendSuccess(array(
1774 'message' => 'CSS saved successfully',
1775 'css' => $css,
1776 'post_id' => $post_id
1777 ));
1778 } else {
1779 $this->sendError('Failed to save CSS');
1780 }
1781 }
1782
1783 /**
1784 * Enhanced CSS sanitization - preserves valid CSS while removing threats
1785 */
1786 private function sanitizeCSS($css) {
1787 // Remove PHP tags first
1788 $css = preg_replace('/<\?php.*?\?>/is', '', $css);
1789
1790 // Remove HTML tags (script, iframe, object, embed)
1791 $css = preg_replace('/<script[^>]*>.*?<\/script>/is', '', $css);
1792 $css = preg_replace('/<iframe[^>]*>.*?<\/iframe>/is', '', $css);
1793 $css = preg_replace('/<object[^>]*>.*?<\/object>/is', '', $css);
1794 $css = preg_replace('/<embed[^>]*>/is', '', $css);
1795
1796 // Remove dangerous CSS constructs
1797 $css = preg_replace('/expression\s*\(/i', '', $css); // CSS expressions
1798 $css = preg_replace('/javascript\s*:/i', '', $css); // JavaScript protocol
1799 $css = preg_replace('/@import\s+url\s*\(/i', '', $css); // @import url()
1800 $css = preg_replace('/@import\s+["\'][^"\']+["\']/', '', $css); // @import with quotes
1801 $css = preg_replace('/behavior\s*:\s*url\s*\(/i', '', $css); // IE behavior
1802 $css = preg_replace('/binding\s*:/i', '', $css); // XBL binding
1803
1804 // Remove dangerous CSS functions (but keep safe ones)
1805 $dangerous_functions = ['eval', 'exec', 'system', 'passthru', 'shell_exec', 'phpinfo', 'file_get_contents', 'file_put_contents', 'fopen', 'fwrite', 'curl_exec'];
1806 foreach ($dangerous_functions as $func) {
1807 $css = preg_replace('/\b' . preg_quote($func, '/') . '\s*\(/i', '', $css);
1808 }
1809
1810 // Remove data URLs that could contain malicious content
1811 $css = preg_replace('/data\s*:\s*["\'][^"\']*["\']/i', '', $css);
1812
1813 // Remove vbscript: protocol
1814 $css = preg_replace('/vbscript\s*:/i', '', $css);
1815
1816 // Remove any remaining HTML-like constructs
1817 $css = htmlspecialchars_decode($css, ENT_QUOTES);
1818
1819 // Basic cleanup - remove excessive whitespace but preserve CSS structure
1820 $css = preg_replace('/\s+/', ' ', $css);
1821 $css = preg_replace('/;\s*}/', '}', $css);
1822 $css = preg_replace('/\s*{\s*/', ' {', $css);
1823 $css = preg_replace('/;\s*;/', ';', $css);
1824
1825 return trim($css);
1826 }
1827 }
1828