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

1,910 lines 77.6 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 // The toast is what the user sees; the field may sit on another tab.
107 wp_send_json_error([
108 'message' => implode(' ', array_map('strval', $invoice_data['errors'])),
109 'errors' => $invoice_data['errors']
110 ]);
111 }
112
113 // Handle items separately - process the natural form submission format
114 if (isset($raw_invoice_data['items']) && is_array($raw_invoice_data['items'])) {
115 // Form submits items as items[0][title], items[0][description], etc.
116 // Convert to array of item objects for processing
117 $items_array = [];
118 foreach ($raw_invoice_data['items'] as $index => $item_data) {
119 if (is_array($item_data)) {
120 $items_array[] = $item_data;
121 }
122 }
123
124 // Process items using the dynamic field system
125 $invoice_data['data']['items'] = $invoice_form_manager->processItemsData($items_array);
126 }
127
128 // Handle special fields that might not be in the form definition
129 if (isset($raw_invoice_data['invoice_id'])) {
130 $invoice_data['data']['invoice_id'] = intval($raw_invoice_data['invoice_id']);
131 }
132
133 if (isset($raw_invoice_data['client_id'])) {
134 $invoice_data['data']['client_id'] = intval($raw_invoice_data['client_id']);
135 }
136
137
138
139 $invoice_id = isset($invoice_data['data']['invoice_id']) ? intval($invoice_data['data']['invoice_id']) : 0;
140
141 $repository = InvoiceServiceProvider::getInvoiceRepository();
142
143 if ($invoice_id > 0) {
144 // Update existing invoice - preserve existing invoice number
145 unset($invoice_data['data']['invoice_number']);
146 unset($invoice_data['data']['number']);
147
148 $invoice = $repository->update($invoice_id, $invoice_data['data']);
149
150 if (!$invoice) {
151
152 $this->sendError(__('Failed to update invoice', 'easy-invoice'));
153 }
154
155 // Use FormProcessor to save form data to database
156 $form_processor = new \EasyInvoice\Forms\FormProcessor();
157 $all_fields = $invoice_form_manager->getAllFields();
158 $form_processor->saveFormDataToDatabase($invoice_data['data'], $all_fields, $invoice);
159
160 $message = __('Invoice updated successfully', 'easy-invoice');
161 } else {
162 // Create new invoice - allow auto-generated invoice number to be saved
163 // The invoice number will be auto-generated by the form and included in the data
164
165 $invoice = $repository->create($invoice_data['data']);
166
167
168 if (!$invoice) {
169 $this->sendError(__('Failed to create invoice', 'easy-invoice'));
170 }
171
172 // The repository claimed a number under its lock (or generated the next
173 // one when the number the builder peeked on page load was taken
174 // meanwhile). Carry that claimed number into the form data: the
175 // FormProcessor below writes every posted field, and the stale peek
176 // would otherwise overwrite the claim — two builders open at once
177 // then saved two documents with the same number.
178 $invoice_data['data']['number'] = $invoice->getNumber();
179 unset($invoice_data['data']['invoice_number']);
180
181 // Use FormProcessor to save form data to database
182 $form_processor = new \EasyInvoice\Forms\FormProcessor();
183 $all_fields = $invoice_form_manager->getAllFields();
184 $form_processor->saveFormDataToDatabase($invoice_data['data'], $all_fields, $invoice);
185
186 $invoice_id = $invoice->getId();
187 $message = __('Invoice created successfully', 'easy-invoice');
188 }
189
190 // Handle items
191 if (isset($invoice_data['data']['items']) && is_array($invoice_data['data']['items'])) {
192 $invoice->setItems($invoice_data['data']['items']);
193 }
194
195 $invoice_template = get_post_meta($invoice_id, '_easy_invoice_invoice_template', true);
196
197 $invoice_template = $invoice_template=='' ? 'standard': $invoice_template;
198
199 update_option('easy_invoice_last_invoice_template',$invoice_template );
200
201 // Prepare response data
202 $response_data = array(
203 'invoice_id' => $invoice_id,
204 'invoice' => $invoice->toArray(),
205 'toast' => array(
206 'type' => 'success',
207 'message' => $message,
208 'options' => array('duration' => 4000)
209 )
210 );
211
212 // Include client data if invoice has a client
213 if ($invoice->getClientId()) {
214 $client_repository = ClientServiceProvider::getClientRepository();
215 $client = $client_repository->find($invoice->getClientId());
216 if ($client) {
217 $response_data['client'] = array(
218 'id' => $client->getId(),
219 'name' => $client->getBusinessClientName() ?: ($client->getFirstName() . ' ' . $client->getLastName()),
220 'email' => $client->getEmail() ?: '',
221 'phone' => $client->getExtraInfo() ?: '',
222 'company' => $client->getBusinessClientName() ?: '',
223 'address' => $client->getAddress() ?: '',
224 'website' => $client->getWebsite() ?: '',
225 );
226 }
227 }
228
229 wp_send_json_success($response_data);
230 }
231
232 /**
233 * Save and send invoice
234 */
235 public function saveAndSendInvoice() {
236 $this->verifyNonce('easy_invoice_nonce');
237
238 // Compound action: needs both create-edit and send rights.
239 if (!easy_invoice_user_can('ei_create_invoice') || !easy_invoice_user_can('ei_send_invoice')) {
240 $this->sendError(__('You do not have permission to perform this action', 'easy-invoice'));
241 }
242
243 // First save the invoice
244 $this->saveInvoice();
245
246 // If we get here, the invoice was saved successfully
247 // Now send the invoice via email
248 $invoice_id = isset($_POST['invoice_data']['invoice_id']) ? intval($_POST['invoice_data']['invoice_id']) : 0;
249
250 if ($invoice_id > 0) {
251 // Send the invoice via email
252 $result = $this->sendInvoiceEmail($invoice_id);
253
254 if ($result['success']) {
255 $this->sendSuccess(array(
256 'message' => __('Invoice saved and sent successfully', 'easy-invoice'),
257 'invoice_id' => $invoice_id
258 ));
259 } else {
260 $this->sendError($result['message']);
261 }
262 } else {
263 $this->sendError(__('Invalid invoice ID for sending', 'easy-invoice'));
264 }
265 }
266
267 /**
268 * Delete invoice
269 */
270 public function deleteInvoice() {
271 $this->verifyNonce('easy_invoice_nonce');
272
273 if (!easy_invoice_user_can('ei_delete_invoice')) {
274 $this->sendError(__('You do not have permission to perform this action', 'easy-invoice'));
275 }
276
277 $invoice_id = isset($_POST['invoice_id']) ? intval($_POST['invoice_id']) : 0;
278
279 if ($invoice_id <= 0) {
280 $this->sendError(__('Invalid invoice ID', 'easy-invoice'));
281 }
282
283 $repository = InvoiceServiceProvider::getInvoiceRepository();
284 $result = $repository->delete($invoice_id);
285
286 if (!$result) {
287 $this->sendError(__('Failed to delete invoice', 'easy-invoice'));
288 }
289
290 $this->sendSuccess(array(
291 'message' => __('Invoice deleted successfully', 'easy-invoice'),
292 'invoice_id' => $invoice_id,
293 ));
294 }
295
296 /**
297 * Get invoice
298 */
299 public function getInvoice() {
300 $this->verifyNonce('easy_invoice_nonce');
301
302 if (!easy_invoice_user_can('ei_view_invoices')) {
303 $this->sendError(__('You do not have permission to perform this action', 'easy-invoice'));
304 }
305
306 $invoice_id = isset($_REQUEST['invoice_id']) ? intval($_REQUEST['invoice_id']) : 0;
307
308 if ($invoice_id <= 0) {
309 $this->sendError(__('Invalid invoice ID', 'easy-invoice'));
310 }
311
312 $repository = InvoiceServiceProvider::getInvoiceRepository();
313 $invoice = $repository->find($invoice_id);
314
315 if (!$invoice) {
316 $this->sendError(__('Invoice not found', 'easy-invoice'));
317 }
318
319 $this->sendSuccess(array(
320 'invoice' => $invoice->toArray(),
321 ));
322 }
323
324 /**
325 * Save client
326 */
327 public function saveClient() {
328 $this->verifyNonce('easy_invoice_nonce');
329
330 if (!easy_invoice_user_can('ei_manage_clients')) {
331 $this->sendError(__('You do not have permission to perform this action', 'easy-invoice'));
332 }
333
334 $client_id = isset($_POST['client_id']) ? intval($_POST['client_id']) : 0;
335 $client_data = isset($_POST['client_data']) ? $this->sanitizeData($_POST['client_data']) : array();
336
337 if (empty($client_data)) {
338 $this->sendError(__('Invalid client data', 'easy-invoice'));
339 }
340
341 $repository = ClientServiceProvider::getClientRepository();
342
343 if ($client_id > 0) {
344 // Update existing client
345 $client = $repository->update($client_id, $client_data);
346
347 if (!$client) {
348 $this->sendError(__('Failed to update client', 'easy-invoice'));
349 }
350
351 $message = __('Client updated successfully', 'easy-invoice');
352 } else {
353 // Create new client
354 $client = $repository->create($client_data);
355
356 if (!$client) {
357 $this->sendError(__('Failed to create client', 'easy-invoice'));
358 }
359
360 $client_id = $client->getId();
361 $message = __('Client created successfully', 'easy-invoice');
362 }
363
364 $this->sendSuccess(array(
365 'message' => $message,
366 'client_id' => $client_id,
367 'client' => $client->toArray(),
368 ));
369 }
370
371 /**
372 * Delete client
373 */
374 public function deleteClient() {
375 try {
376 $this->verifyNonce('easy_invoice_nonce');
377
378 if (!easy_invoice_user_can('ei_manage_clients')) {
379 $this->sendError(__('You do not have permission to perform this action', 'easy-invoice'));
380 }
381
382 $client_id = isset($_POST['client_id']) ? intval($_POST['client_id']) : 0;
383 // The dialog posts the literal strings "true" / "false"; a bool cast
384 // made "false" true, so "Delete client only" removed the documents too.
385 $delete_associated_documents = isset($_POST['delete_associated_documents'])
386 && filter_var(wp_unslash($_POST['delete_associated_documents']), FILTER_VALIDATE_BOOLEAN);
387
388 if ($client_id <= 0) {
389 $this->sendError(__('Invalid client ID', 'easy-invoice'));
390 }
391
392 // Check if the user exists and is not an administrator
393 $user = get_user_by('ID', $client_id);
394 if (!$user) {
395 $this->sendError(__('User not found', 'easy-invoice'));
396 }
397
398 if (in_array('administrator', $user->roles)) {
399 $this->sendError(__('Cannot delete administrator accounts', 'easy-invoice'));
400 }
401
402 global $wpdb;
403
404 // Get counts of associated documents
405 $invoice_count = $wpdb->get_var($wpdb->prepare(
406 "SELECT COUNT(*) FROM {$wpdb->postmeta} WHERE meta_key = '_easy_invoice_client_id' AND meta_value = %d",
407 $client_id
408 ));
409
410 $quote_count = $wpdb->get_var($wpdb->prepare(
411 "SELECT COUNT(*) FROM {$wpdb->postmeta} WHERE meta_key = '_easy_invoice_quote_client_id' AND meta_value = %d",
412 $client_id
413 ));
414
415 // Counted through the client's invoices, because that is the only
416 // link there is: payments record '_invoice_id' and no client id.
417 // This counted meta '_easy_payment_client_id', which nothing writes,
418 // so the confirmation dialog told every user there were no payments
419 // no matter how many there were.
420 $payment_count = count(\EasyInvoice\Services\ClientLedger::paymentIds((int) $client_id));
421
422 $total_documents = $invoice_count + $quote_count + $payment_count;
423
424 if ($delete_associated_documents) {
425 // Delete all associated documents
426 // error_log(), not $this->log(): no such method exists on this class or
427 // any trait it uses, so both branches of this handler raised
428 // "Call to undefined method" — deleting a client failed with a critical
429 // error whichever option the administrator chose. Matches the logging
430 // used elsewhere in the plugin.
431 error_log(sprintf('Easy Invoice: deleting client %d with all associated documents (%d invoices, %d quotes, %d payments)',
432 $client_id, $invoice_count, $quote_count, $payment_count));
433
434 // Delete invoices
435 if ($invoice_count > 0) {
436 $invoices = $wpdb->get_col($wpdb->prepare(
437 "SELECT post_id FROM {$wpdb->postmeta} WHERE meta_key = '_easy_invoice_client_id' AND meta_value = %d",
438 $client_id
439 ));
440 foreach ($invoices as $invoice_id) {
441 wp_delete_post($invoice_id, true);
442 }
443 }
444
445 // Delete quotes
446 if ($quote_count > 0) {
447 $quotes = $wpdb->get_col($wpdb->prepare(
448 "SELECT post_id FROM {$wpdb->postmeta} WHERE meta_key = '_easy_invoice_quote_client_id' AND meta_value = %d",
449 $client_id
450 ));
451 foreach ($quotes as $quote_id) {
452 wp_delete_post($quote_id, true);
453 }
454 }
455
456 // Delete payments
457 //
458 // This queried '_easy_invoice_payment_client_id' while the matching
459 // count above (and the meta cleanup below, and
460 // ClientRepository::countClientPayments) all query
461 // '_easy_payment_client_id' — so the count and the deletion disagreed
462 // on which key identifies a client's payments. Unified on
463 // '_easy_payment_client_id', the key the other three sites use.
464 //
465 // Payments are RETAINED, on purpose, and the message below says so.
466 //
467 // Neither of those meta keys is ever written: a payment stores
468 // '_invoice_id' and carries no client id at all, so it is linked to
469 // a client only through its invoice. Client deletion has therefore
470 // never removed a payment, whatever the dialog implied.
471 //
472 // $payment_count is now resolved correctly through ClientLedger, so
473 // the count is true even though the behaviour is unchanged. Making
474 // the deletion true as well would destroy records this path has
475 // never touched — a payment is the evidence money changed hands, and
476 // the reasoning that stops InvoiceRetention deleting an issued
477 // invoice applies to it. That is a deliberate decision to keep them,
478 // not an oversight, so it is stated to the user rather than hidden.
479
480 // Say what was kept as well as what went. "All associated documents
481 // deleted" was never true where payments were concerned, and a
482 // merchant who believes their payment records are gone will look
483 // for them in the wrong place at the wrong time of year.
484 $message = $payment_count > 0
485 ? sprintf(
486 /* translators: 1: number of documents deleted, 2: number of payment records kept. */
487 _n(
488 'Client deleted, along with %1$d document. %2$d payment record was kept as a financial record.',
489 'Client deleted, along with %1$d documents. %2$d payment records were kept as financial records.',
490 $payment_count,
491 'easy-invoice'
492 ),
493 $invoice_count + $quote_count,
494 $payment_count
495 )
496 : sprintf(
497 /* translators: %d: number of documents deleted. */
498 __('Client and all associated documents (%d total) deleted successfully', 'easy-invoice'),
499 $total_documents
500 );
501 } else {
502 // Only remove client associations, preserve documents
503 // See the note on the other branch above.
504 error_log(sprintf('Easy Invoice: removing client associations for client %d (%d invoices, %d quotes, %d payments)',
505 $client_id, $invoice_count, $quote_count, $payment_count));
506
507 // Remove client associations from invoices
508 if ($invoice_count > 0) {
509 $wpdb->delete(
510 $wpdb->postmeta,
511 ['meta_key' => '_easy_invoice_client_id', 'meta_value' => $client_id]
512 );
513 }
514
515 // Remove client associations from quotes
516 if ($quote_count > 0) {
517 $wpdb->delete(
518 $wpdb->postmeta,
519 ['meta_key' => '_easy_invoice_quote_client_id', 'meta_value' => $client_id]
520 );
521 }
522
523 // Remove client associations from payments
524 if ($payment_count > 0) {
525 $wpdb->delete(
526 $wpdb->postmeta,
527 ['meta_key' => '_easy_payment_client_id', 'meta_value' => $client_id]
528 );
529 }
530
531 /* translators: %d: number of documents. */
532 $message = sprintf(__('Client deleted successfully. %d documents preserved but client associations removed.', 'easy-invoice'), $total_documents);
533 }
534
535 // Snapshot identity BEFORE delete — once wp_delete_user runs the
536 // user record is gone and we can't backfill the audit context.
537 $deleted_login = $user && $user->user_login ? $user->user_login : '';
538 $deleted_email = $user && $user->user_email ? $user->user_email : '';
539
540 // Delete the WordPress user
541 require_once(ABSPATH . 'wp-admin/includes/user.php');
542 $result = wp_delete_user($client_id);
543
544 if (!$result) {
545 $this->sendError(__('Failed to delete client', 'easy-invoice'));
546 }
547
548 // Audit: record the delete with enough context to investigate later.
549 if (function_exists('easy_invoice_audit_log')) {
550 easy_invoice_audit_log('client_deleted', 'client', $client_id, [
551 'login' => $deleted_login,
552 'email' => $deleted_email,
553 'invoices_affected' => (int) $invoice_count,
554 'quotes_affected' => (int) $quote_count,
555 'payments_affected' => (int) $payment_count,
556 'cascade_delete' => $delete_associated_documents,
557 ]);
558 }
559
560 $this->sendSuccess(array(
561 'message' => $message,
562 'client_id' => $client_id,
563 'documents_deleted' => $delete_associated_documents,
564 'total_documents' => $total_documents
565 ));
566
567 } catch (\Exception $e) {
568 $this->sendError($e->getMessage());
569 }
570 }
571
572 /**
573 * Get client
574 */
575 public function getClient() {
576 $this->verifyNonce('easy_invoice_nonce');
577
578 if (!easy_invoice_user_can('ei_view_clients')) {
579 $this->sendError(__('You do not have permission to perform this action', 'easy-invoice'));
580 }
581
582 $client_id = isset($_REQUEST['client_id']) ? intval($_REQUEST['client_id']) : 0;
583
584 if ($client_id <= 0) {
585 $this->sendError(__('Invalid client ID', 'easy-invoice'));
586 }
587
588 $repository = ClientServiceProvider::getClientRepository();
589 $client = $repository->find($client_id);
590
591 if (!$client) {
592 $this->sendError(__('Client not found', 'easy-invoice'));
593 }
594
595 $client_data = $client->toArray();
596
597 // Return comprehensive client data in a unified format that works for both form population and display
598 $this->sendSuccess(array(
599 // Form population fields (for invoice-builder.js and invoice-form.js)
600 'name' => $client_data['company_name'] ?? $client_data['contact_name'] ?? '',
601 'email' => $client_data['email'] ?? '',
602 'phone' => $client_data['phone'] ?? '',
603 'company' => $client_data['company_name'] ?? '',
604 'address' => $client_data['billing_address'] ?? '',
605 'website' => $client_data['website'] ?? '',
606
607 // Display fields (for client-manager.js)
608 'business_client_name' => $client->getBusinessClientName(),
609 'username' => $client->getUsername(),
610 'extra_info' => $client->getExtraInfo(),
611 'first_name' => $client->getFirstName(),
612 'last_name' => $client->getLastName(),
613
614 // Raw data for backward compatibility
615 'client' => array(
616 'name' => $client_data['company_name'] ?? $client_data['contact_name'] ?? '',
617 'email' => $client_data['email'] ?? '',
618 'phone' => $client_data['phone'] ?? '',
619 'company' => $client_data['company_name'] ?? '',
620 'address' => $client_data['billing_address'] ?? '',
621 'website' => $client_data['website'] ?? '',
622 )
623 ));
624 }
625
626 /**
627 * Verify nonce
628 *
629 * @param string $action The nonce action
630 */
631 private function verifyNonce($action) {
632 // Check for _nonce (standard format) first
633 if (isset($_REQUEST['_nonce']) && wp_verify_nonce($_REQUEST['_nonce'], $action)) {
634 return;
635 }
636
637 // Also check for 'nonce' (client form format)
638 if (isset($_REQUEST['nonce']) && wp_verify_nonce($_REQUEST['nonce'], $action)) {
639 return;
640 }
641
642 // If we get here, neither nonce format was valid
643 $this->sendError(__('Security check failed', 'easy-invoice'));
644 }
645
646 /**
647 * Sanitize data
648 *
649 * @param array $data The data to sanitize
650 * @return array The sanitized data
651 */
652 private function sanitizeData($data) {
653 if (!is_array($data)) {
654 return array();
655 }
656
657 $sanitized = array();
658
659 // Define fields that should allow HTML (like textarea content)
660 $html_fields = [
661 'invoice_description', 'description', 'notes', 'terms',
662 'internal_notes', 'customer_address'
663 ];
664
665 // Define numeric fields
666 $numeric_fields = [
667 'invoice_id', 'client_id', 'discount_value', 'tax_rate'
668 ];
669
670 foreach ($data as $key => $value) {
671 if (is_array($value)) {
672 $sanitized[$key] = $this->sanitizeData($value);
673 } else if (in_array($key, $html_fields)) {
674 // For HTML fields, use wp_kses to allow certain tags but prevent XSS
675 $sanitized[$key] = wp_kses_post($value);
676 } else if (in_array($key, $numeric_fields)) {
677 // For numeric fields, ensure they're valid numbers
678 $sanitized[$key] = is_numeric($value) ? $value : 0;
679 } else {
680 $sanitized[$key] = sanitize_text_field($value);
681 }
682 }
683
684 return $sanitized;
685 }
686
687 /**
688 * Send success response
689 */
690 private function sendSuccess($data = array()) {
691 // Check if we should suppress global toast
692 $suppress_toast = isset($_POST['suppress_global_toast']) && $_POST['suppress_global_toast'] === 'true';
693
694 // Only inject the toast key when $data is an associative array
695 // (or empty). If $data is a numeric-indexed list (e.g. search
696 // results), adding a string key would mutate the array shape:
697 // PHP keeps the mixed keys, but `wp_send_json_success` then
698 // serialises the value as a JSON OBJECT instead of an array,
699 // breaking any frontend that does `response.data.length` or
700 // `response.data.forEach(...)` — the exact bug that caused the
701 // client-search dropdown to silently render empty results.
702 $is_assoc_or_empty = !is_array($data)
703 || empty($data)
704 || array_keys($data) !== range(0, count($data) - 1);
705
706 if ($is_assoc_or_empty && !isset($data['toast']) && !$suppress_toast) {
707 $message = isset($data['message']) ? $data['message'] : __('Operation completed successfully', 'easy-invoice');
708 $data['toast'] = array(
709 'type' => 'success',
710 'message' => $message,
711 'options' => array('duration' => 4000)
712 );
713 }
714
715 // Remove toast data if suppressed
716 if ($is_assoc_or_empty && $suppress_toast && isset($data['toast'])) {
717 unset($data['toast']);
718 }
719
720 wp_send_json_success($data);
721 }
722
723 /**
724 * Send error response
725 */
726 private function sendError($message, $data = array()) {
727 // Add toast notification
728 $data['toast'] = array(
729 'type' => 'error',
730 'message' => $message,
731 'options' => array('duration' => 6000)
732 );
733
734 wp_send_json_error($data);
735 }
736
737 /**
738 * Download invoice as PDF
739 */
740 public function downloadPdf() {
741 // Verify nonce
742 $this->verifyNonce('easy_invoice_nonce');
743
744 // Check if user has required capability
745 if (!easy_invoice_user_can('ei_view_invoices')) {
746 $this->sendError(__('You do not have permission to download invoices', 'easy-invoice'));
747 }
748
749 // Get invoice ID
750 $invoice_id = isset($_REQUEST['invoice_id']) ? intval($_REQUEST['invoice_id']) : 0;
751
752 if (!$invoice_id) {
753 $this->sendError(__('Invalid invoice ID', 'easy-invoice'));
754 }
755
756 // Get invoice from repository
757 $repository = InvoiceServiceProvider::getInvoiceRepository();
758 $invoice = $repository->find($invoice_id);
759
760 if (!$invoice) {
761 $this->sendError(__('Invoice not found', 'easy-invoice'));
762 }
763
764 // Get invoice data for PDF generation
765 $invoice_data = \EasyInvoice\Includes\Helpers\PdfHelper::getInvoiceDataForPdf($invoice);
766
767 // Return success response with invoice data
768 $this->sendSuccess(array(
769 'message' => __('Invoice data retrieved successfully', 'easy-invoice'),
770 'invoice_data' => $invoice_data
771 ));
772 }
773
774 /**
775 * Send invoice via email
776 */
777 public function sendInvoiceEmail() {
778 $this->verifyNonce('easy_invoice_nonce');
779
780 if (!easy_invoice_user_can('ei_send_invoice')) {
781 $this->sendError(__('You do not have permission to perform this action', 'easy-invoice'));
782 }
783
784 // Get invoice ID from POST data
785 $invoice_id = isset($_POST['invoice_id']) ? intval($_POST['invoice_id']) : 0;
786
787 if (!$invoice_id) {
788 $this->sendError(__('Invalid invoice ID', 'easy-invoice'));
789 }
790
791 $repository = InvoiceServiceProvider::getInvoiceRepository();
792 $invoice = $repository->find($invoice_id);
793
794 if (!$invoice) {
795 $this->sendError(__('Invoice not found', 'easy-invoice'));
796 }
797
798 // Use EmailManager to send the email
799 $email_manager = \EasyInvoice\Services\EmailManager::getInstance();
800 $result = $email_manager->sendInvoiceEmail($invoice, 'new');
801
802 if ($result['success']) {
803 // Audit: who sent which invoice to which client, at what time.
804 if (function_exists('easy_invoice_audit_log')) {
805 easy_invoice_audit_log('invoice_sent', 'invoice', $invoice_id, [
806 'recipient' => is_callable([$invoice, 'getCustomerEmail']) ? $invoice->getCustomerEmail() : '',
807 'context' => 'new',
808 ]);
809 }
810 $this->sendSuccess(array(
811 'message' => $result['message']
812 ));
813 } else {
814 $this->sendError($result['message']);
815 }
816 }
817
818 /**
819 * Download quote as PDF
820 */
821 public function downloadQuotePdf() {
822 // Verify nonce
823 $this->verifyNonce('easy_invoice_nonce');
824
825 // Check if user has required capability
826 if (!easy_invoice_user_can('ei_view_quotes')) {
827 $this->sendError(__('You do not have permission to download quotes', 'easy-invoice'));
828 }
829
830 // Get quote ID
831 $quote_id = isset($_POST['quote_id']) ? intval($_POST['quote_id']) : 0;
832
833 if (!$quote_id) {
834 $this->sendError(__('Invalid quote ID', 'easy-invoice'));
835 }
836
837 // Get quote from repository
838 $repository = \EasyInvoice\Providers\QuoteServiceProvider::getQuoteRepository();
839 $quote = $repository->find($quote_id);
840
841 if (!$quote) {
842 $this->sendError(__('Quote not found', 'easy-invoice'));
843 }
844
845 // For now, return success response with quote data
846 // PDF generation can be implemented later with actual PDF creation
847 $this->sendSuccess(array(
848 'message' => __('Quote data retrieved successfully', 'easy-invoice'),
849 'quote_data' => $quote->toArray(),
850 'download_url' => add_query_arg(array(
851 'action' => 'easy_invoice_generate_quote_pdf',
852 'quote_id' => $quote_id,
853 // Bound to this quote — see the invoice equivalent above.
854 'nonce' => wp_create_nonce('generate_quote_pdf_' . $quote_id)
855 ), admin_url('admin-ajax.php'))
856 ));
857 }
858
859 /**
860 * Save quote
861 */
862 public function saveQuote() {
863 $this->verifyNonce('easy_invoice_nonce');
864
865 if (!easy_invoice_user_can('ei_create_quote')) {
866 $this->sendError(__('You do not have permission to perform this action', 'easy-invoice'));
867 }
868
869 // Get the raw quote data from the form
870 $raw_quote_data = isset($_POST['quote_data']) ? $_POST['quote_data'] : $_POST;
871
872 // Remove non-quote fields
873 unset($raw_quote_data['action']);
874 unset($raw_quote_data['nonce']);
875
876 // Process the quote data
877 $quote_form_manager = new \EasyInvoice\Forms\Quote\QuoteFormManager();
878 $quote_data = $quote_form_manager->processFormData($raw_quote_data);
879
880 if (!empty($quote_data['errors'])) {
881 wp_send_json_error([
882 'message' => implode(' ', array_map('strval', $quote_data['errors'])),
883 'errors' => $quote_data['errors']
884 ]);
885 }
886
887 // Handle items separately - process the natural form submission format
888 if (isset($raw_quote_data['items']) && is_array($raw_quote_data['items'])) {
889 // Form submits items as items[0][title], items[0][description], etc.
890 // Convert to array of item objects for processing
891 $items_array = [];
892 foreach ($raw_quote_data['items'] as $index => $item_data) {
893 if (is_array($item_data)) {
894 $items_array[] = $item_data;
895 }
896 }
897
898 // Process items using the dynamic field system
899 $quote_data['data']['items'] = $quote_form_manager->processItemsData($items_array);
900 }
901
902 // Handle special fields that might not be in the form definition
903 if (isset($raw_quote_data['quote_id'])) {
904 $quote_data['data']['quote_id'] = intval($raw_quote_data['quote_id']);
905 }
906
907 if (isset($raw_quote_data['client_id'])) {
908 $quote_data['data']['client_id'] = intval($raw_quote_data['client_id']);
909 }
910
911
912
913 $quote_id = isset($quote_data['data']['quote_id']) ? intval($quote_data['data']['quote_id']) : 0;
914
915 $repository = \EasyInvoice\Providers\QuoteServiceProvider::getQuoteRepository();
916
917 if ($quote_id > 0) {
918 // Update existing quote - preserve existing quote number
919 unset($quote_data['data']['quote_number']);
920 unset($quote_data['data']['number']);
921
922 // Get the existing quote first
923 $quote = $repository->find($quote_id);
924
925 if (!$quote) {
926 $this->sendError(__('Failed to find quote for update', 'easy-invoice'));
927 }
928
929 // Use FormProcessor to save form data to database BEFORE repository update
930 $form_processor = new \EasyInvoice\Forms\FormProcessor();
931 $all_fields = $quote_form_manager->getAllFields();
932 $form_processor->saveFormDataToDatabase($quote_data['data'], $all_fields, $quote);
933
934 // Now update the quote with the processed data, passing the existing quote object
935 $quote = $repository->update($quote_id, $quote_data['data'], $quote);
936
937 if (!$quote) {
938 $this->sendError(__('Failed to update quote', 'easy-invoice'));
939 }
940
941 $message = __('Quote updated successfully', 'easy-invoice');
942 } else {
943 // Create new quote - allow auto-generated quote number to be saved
944 // The quote number will be auto-generated by the form and included in the data
945
946 $quote = $repository->create($quote_data['data']);
947
948 if (!$quote) {
949 $this->sendError(__('Failed to create quote', 'easy-invoice'));
950 }
951
952 // Same as invoices: keep the number the repository claimed, not the one
953 // the builder peeked on page load (see saveInvoice()).
954 $quote_data['data']['number'] = $quote->getNumber();
955 unset($quote_data['data']['quote_number']);
956
957 // Use FormProcessor to save form data to database
958 $form_processor = new \EasyInvoice\Forms\FormProcessor();
959 $all_fields = $quote_form_manager->getAllFields();
960 $form_processor->saveFormDataToDatabase($quote_data['data'], $all_fields, $quote);
961
962 $quote_id = $quote->getId();
963 $message = __('Quote created successfully', 'easy-invoice');
964 }
965
966 // Handle items
967 if (isset($quote_data['data']['items']) && is_array($quote_data['data']['items'])) {
968 $quote->setItems($quote_data['data']['items']);
969 // Save the quote to persist the items to database
970 $quote->save();
971 }
972
973 $quote_template = get_post_meta($quote_id, '_easy_invoice_quote_quote_template', true);
974
975 $quote_template = $quote_template=='' ? 'standard': $quote_template;
976
977 update_option('easy_invoice_last_quote_template',$quote_template );
978 // Prepare response data
979 $response_data = array(
980 'quote_id' => $quote_id,
981 'quote' => $quote->toArray(),
982 'toast' => array(
983 'type' => 'success',
984 'message' => $message,
985 'options' => array('duration' => 4000)
986 )
987 );
988
989 // Include client data if quote has a client
990 if ($quote->getClientId()) {
991 $client_repository = ClientServiceProvider::getClientRepository();
992 $client = $client_repository->find($quote->getClientId());
993 if ($client) {
994 $response_data['client'] = array(
995 'id' => $client->getId(),
996 'name' => $client->getBusinessClientName() ?: ($client->getFirstName() . ' ' . $client->getLastName()),
997 'email' => $client->getEmail() ?: '',
998 'phone' => $client->getExtraInfo() ?: '',
999 'company' => $client->getBusinessClientName() ?: '',
1000 'address' => $client->getAddress() ?: '',
1001 'website' => $client->getWebsite() ?: '',
1002 );
1003 }
1004 }
1005
1006
1007 $this->sendSuccess($response_data);
1008 }
1009
1010 /**
1011 * Toggle a template as favorite
1012 */
1013
1014
1015
1016
1017 /**
1018 * Check if an email already exists for any client
1019 */
1020 public function checkEmailExists() {
1021 $this->verifyNonce('easy_invoice_nonce');
1022
1023 // Email-lookup is used during client creation; anyone who can manage
1024 // clients can check duplicates.
1025 if (!easy_invoice_user_can('ei_manage_clients')) {
1026 $this->sendError(__('You do not have permission to perform this action', 'easy-invoice'));
1027 }
1028
1029 $email = isset($_POST['email']) ? sanitize_email($_POST['email']) : '';
1030
1031 if (empty($email)) {
1032 $this->sendSuccess(array('exists' => false));
1033 }
1034
1035 $repository = ClientServiceProvider::getClientRepository();
1036 $existing_clients = $repository->findByEmail($email);
1037
1038 $this->sendSuccess(array(
1039 'exists' => !empty($existing_clients),
1040 'count' => count($existing_clients)
1041 ));
1042 }
1043
1044 /**
1045 * Generate a secure password.
1046 */
1047 public function generatePassword() {
1048 $this->verifyNonce('easy_invoice_nonce');
1049
1050 // Used when creating a client (WP user); same gate as client management.
1051 if (!easy_invoice_user_can('ei_manage_clients')) {
1052 $this->sendError(__('You do not have permission to perform this action', 'easy-invoice'));
1053 }
1054
1055 $password = wp_generate_password(16, true, true);
1056
1057 // Check if we should suppress global toast
1058 $suppress_toast = isset($_POST['suppress_global_toast']) && $_POST['suppress_global_toast'] === 'true';
1059
1060 $this->sendSuccess(array(
1061 'password' => $password,
1062 'suppress_toast' => $suppress_toast
1063 ));
1064 }
1065
1066 /**
1067 * Sanitize invoice items
1068 *
1069 * @param array $items Raw items data
1070 * @return array Sanitized items data
1071 */
1072 private function sanitizeItems(array $items): array {
1073 $sanitized_items = [];
1074
1075 // Get field configuration for dynamic processing
1076 $form_manager = new \EasyInvoice\Forms\Invoice\InvoiceFormManager();
1077 $field_config = $form_manager->getItemFields();
1078
1079 foreach ($items as $item) {
1080 if (!is_array($item)) {
1081 continue;
1082 }
1083
1084 $sanitized_item = [];
1085
1086 // Process each field dynamically based on configuration
1087 foreach ($field_config as $field) {
1088 $field_name = $field['name'] ?? '';
1089 $field_type = $field['type'] ?? 'text';
1090 $raw_value = $item[$field_name] ?? '';
1091
1092 // Apply field-specific sanitization
1093 switch ($field_type) {
1094 case 'text':
1095 $sanitized_item[$field_name] = sanitize_text_field($raw_value);
1096 break;
1097 case 'textarea':
1098 $sanitized_item[$field_name] = wp_kses_post($raw_value);
1099 break;
1100 case 'number':
1101 $sanitized_item[$field_name] = is_numeric($raw_value) ? floatval($raw_value) : 0;
1102 break;
1103 case 'checkbox':
1104 $sanitized_item[$field_name] = !empty($raw_value) ? true : false;
1105 break;
1106 default:
1107 $sanitized_item[$field_name] = sanitize_text_field($raw_value);
1108 break;
1109 }
1110 }
1111
1112 // Handle legacy field names for backward compatibility
1113 if (isset($item['name']) && !isset($sanitized_item['title'])) {
1114 $sanitized_item['title'] = sanitize_text_field($item['name']);
1115 }
1116 if (isset($item['title']) && !isset($sanitized_item['title'])) {
1117 $sanitized_item['title'] = sanitize_text_field($item['title']);
1118 }
1119
1120 // Only add items that have at least a title/name
1121 if (!empty($sanitized_item['title'])) {
1122 $sanitized_items[] = $sanitized_item;
1123 }
1124 }
1125
1126 return $sanitized_items;
1127 }
1128
1129 /**
1130 * Add a new client (specifically for the client form in templates/clients-page.php)
1131 */
1132 public function addClient() {
1133
1134 $this->verifyNonce('easy_invoice_nonce');
1135
1136 if (!easy_invoice_user_can('ei_manage_clients')) {
1137 $this->sendError(__('You do not have permission to perform this action', 'easy-invoice'));
1138 }
1139
1140 // Check if required fields are present
1141 $required_fields = ['business_client_name', 'email', 'username'];
1142 foreach ($required_fields as $field) {
1143 if (!isset($_POST[$field]) || empty($_POST[$field])) {
1144 /* translators: %s: form field name. */
1145 $this->sendError(sprintf(__('Missing required field: %s', 'easy-invoice'), $field));
1146 }
1147 }
1148
1149 // Prepare client data
1150 // Optional fields may be absent from the request entirely.
1151 $post_text = static function ($key) { return isset($_POST[$key]) ? sanitize_text_field(wp_unslash($_POST[$key])) : ''; }; // phpcs:ignore WordPress.Security.NonceVerification.Missing -- checked above.
1152 $post_area = static function ($key) { return isset($_POST[$key]) ? sanitize_textarea_field(wp_unslash($_POST[$key])) : ''; }; // phpcs:ignore WordPress.Security.NonceVerification.Missing
1153 $client_data = [
1154 ClientFields::BUSINESS_CLIENT_NAME => $post_text('business_client_name'),
1155 ClientFields::EMAIL => sanitize_email(wp_unslash(($_POST['email'] ?? ''))),
1156 ClientFields::USERNAME => sanitize_user(wp_unslash(($_POST['username'] ?? ''))),
1157 ClientFields::PASSWORD => isset($_POST['password']) ? (string) wp_unslash($_POST['password']) : '',
1158 ClientFields::ADDRESS => $post_area('address'),
1159 ClientFields::EXTRA_INFO => $post_area('extra_info'),
1160 ClientFields::FIRST_NAME => $post_text('first_name'),
1161 ClientFields::LAST_NAME => $post_text('last_name'),
1162 ClientFields::WEBSITE => isset($_POST['website']) ? esc_url_raw(wp_unslash($_POST['website'])) : '',
1163 ClientFields::PHONE => isset($_POST['phone']) ? sanitize_text_field($_POST['phone']) : '',
1164 ];
1165
1166
1167
1168 // Basic validation
1169 if (empty($client_data[ClientFields::BUSINESS_CLIENT_NAME]) && (empty($client_data[ClientFields::FIRST_NAME]) || empty($client_data[ClientFields::LAST_NAME]))) {
1170 $this->sendError(__('Please provide a client name or first/last name.', 'easy-invoice'));
1171 }
1172
1173 if (empty($client_data[ClientFields::EMAIL])) {
1174 $this->sendError(__('Email address is required', 'easy-invoice'));
1175 }
1176
1177 $repository = ClientServiceProvider::getClientRepository();
1178
1179 // Create new client
1180 $client = $repository->create($client_data);
1181
1182 if (!$client) {
1183 $reason = method_exists($repository, 'getLastError') ? $repository->getLastError() : '';
1184 $this->sendError($reason !== '' ? $reason : __('Failed to create client', 'easy-invoice'));
1185 }
1186
1187 $client_id = $client->getId();
1188
1189 // Pull the WP role assigned during user creation. The
1190 // Clients-page row template needs this so the new-row badge
1191 // matches the role that will be re-rendered server-side on the
1192 // next page load. Without this, the JS template would have to
1193 // hardcode a role label and could drift from PHP's value.
1194 $user = get_user_by('id', $client_id);
1195 $role = ($user && !empty($user->roles)) ? (string) $user->roles[0] : 'customer';
1196
1197 $response_data = array(
1198 'message' => __('Client added successfully', 'easy-invoice'),
1199 'client_id' => $client_id,
1200 'role' => $role,
1201 'role_label' => ucfirst($role),
1202 'client' => $client->toArray(),
1203 );
1204
1205 $this->sendSuccess($response_data);
1206 }
1207
1208 /**
1209 * Update client from the client edit form
1210 */
1211 public function updateClient() {
1212 $this->verifyNonce('easy_invoice_nonce');
1213
1214 if (!easy_invoice_user_can('ei_manage_clients')) {
1215 $this->sendError(__('You do not have permission to perform this action', 'easy-invoice'));
1216 }
1217
1218 $client_id = isset($_POST['client_id']) ? intval($_POST['client_id']) : 0;
1219
1220 if ($client_id <= 0) {
1221 $this->sendError(__('Invalid client ID', 'easy-invoice'));
1222 }
1223
1224 // Prepare client data
1225 $client_data = [
1226 ClientFields::BUSINESS_CLIENT_NAME => sanitize_text_field(($_POST['business_client_name'] ?? '')),
1227 ClientFields::EMAIL => sanitize_email(($_POST['email'] ?? '')),
1228 ClientFields::USERNAME => sanitize_user(($_POST['username'] ?? '')),
1229 ClientFields::PASSWORD => ($_POST['password'] ?? ''), // Keep password as is, don't sanitize
1230 ClientFields::ADDRESS => sanitize_textarea_field(($_POST['address'] ?? '')),
1231 ClientFields::PHONE => isset($_POST['phone']) ? sanitize_text_field($_POST['phone']) : '',
1232 ClientFields::EXTRA_INFO => sanitize_textarea_field(($_POST['extra_info'] ?? '')),
1233 ClientFields::FIRST_NAME => sanitize_text_field(($_POST['first_name'] ?? '')),
1234 ClientFields::LAST_NAME => sanitize_text_field(($_POST['last_name'] ?? '')),
1235 ClientFields::WEBSITE => esc_url_raw(($_POST['website'] ?? ''))
1236 ];
1237
1238 // Remove empty values except password (password can be empty for updates)
1239 $client_data = array_filter($client_data, function($value, $key) {
1240 if ($key === ClientFields::PASSWORD) {
1241 return true; // Always include password field
1242 }
1243 return $value !== '';
1244 }, ARRAY_FILTER_USE_BOTH);
1245
1246 if (empty($client_data)) {
1247 $this->sendError(__('No data provided to update.', 'easy-invoice'));
1248 }
1249
1250 $repository = ClientServiceProvider::getClientRepository();
1251
1252 // Update existing client
1253 $client = $repository->update($client_id, $client_data);
1254
1255 if (!$client) {
1256 $this->sendError(__('Failed to update client', 'easy-invoice'));
1257 }
1258
1259 $this->sendSuccess(array(
1260 'message' => __('Client updated successfully', 'easy-invoice'),
1261 'client_id' => $client_id,
1262 'client' => $client->toArray(),
1263 ));
1264 }
1265
1266 /**
1267 * Update invoices data with missing client information and totals
1268 */
1269 public function updateInvoicesData() {
1270 $this->verifyNonce('easy_invoice_admin_nonce');
1271
1272 // Bulk migration / repair of invoice records — admin-only.
1273 if (!current_user_can('manage_options')) {
1274 $this->sendError(__('You do not have permission to perform this action', 'easy-invoice'));
1275 }
1276
1277 $repository = InvoiceServiceProvider::getInvoiceRepository();
1278 $client_repository = ClientServiceProvider::getClientRepository();
1279
1280 // Get all invoices
1281 $invoices = $repository->all();
1282 $updated_count = 0;
1283
1284 foreach ($invoices as $invoice) {
1285 $updated = false;
1286
1287 // Check if client data is missing
1288 $client_id = $invoice->getClientId();
1289 if ($client_id > 0) {
1290 $client = $client_repository->find($client_id);
1291 if ($client) {
1292 // Update customer name if missing
1293 $customer_name = $invoice->getCustomerName();
1294 if (empty($customer_name)) {
1295 $customer_name = $client->getBusinessClientName() ?: ($client->getFirstName() . ' ' . $client->getLastName());
1296 $invoice->setCustomerName($customer_name);
1297 $updated = true;
1298 }
1299
1300 // Update customer email if missing
1301 $customer_email = $invoice->getCustomerEmail();
1302 if (empty($customer_email)) {
1303 $customer_email = $client->getEmail();
1304 $invoice->setCustomerEmail($customer_email);
1305 $updated = true;
1306 }
1307
1308 // Update customer address if missing
1309 $customer_address = $invoice->getCustomerAddress();
1310 if (empty($customer_address)) {
1311 $customer_address = $client->getAddress();
1312 $invoice->setCustomerAddress($customer_address);
1313 $updated = true;
1314 }
1315 }
1316 }
1317
1318 // Check if total is missing or zero
1319 $total = $invoice->getTotal();
1320 if (empty($total) || $total == 0) {
1321 // Recalculate total from items
1322 $items = $invoice->getItems();
1323 if (!empty($items)) {
1324 $subtotal = 0;
1325 foreach ($items as $item) {
1326 if (method_exists($item, 'getAmount')) {
1327 $subtotal += $item->getAmount();
1328 } elseif (isset($item['amount'])) {
1329 $subtotal += $item['amount'];
1330 }
1331 }
1332
1333 // Calculate discount and tax
1334 $discount = $invoice->getDiscountAmount();
1335 $tax = $invoice->getTaxAmount();
1336
1337 $total = $subtotal - $discount + $tax;
1338
1339 // Save the calculated total
1340 $invoice->setMeta('_easy_invoice_total', $total);
1341 $updated = true;
1342 }
1343 }
1344
1345 if ($updated) {
1346 $updated_count++;
1347 }
1348 }
1349
1350 $this->sendSuccess(array(
1351 /* translators: %d: number updated. */
1352 'message' => sprintf(__('Updated %d invoices with missing data', 'easy-invoice'), $updated_count),
1353 'updated_count' => $updated_count
1354 ));
1355 }
1356
1357 /**
1358 * Download invoice as PDF (public access)
1359 */
1360 public function downloadInvoicePdf() {
1361 // Verify nonce
1362 $this->verifyNonce('easy_invoice_nonce');
1363
1364 // Get invoice ID
1365 $invoice_id = isset($_POST['invoice_id']) ? intval($_POST['invoice_id']) : 0;
1366
1367 if (!$invoice_id) {
1368 $this->sendError(__('Invalid invoice ID', 'easy-invoice'));
1369 }
1370
1371 $repository = InvoiceServiceProvider::getInvoiceRepository();
1372 $invoice = $repository->find($invoice_id);
1373
1374 if (!$invoice) {
1375 $this->sendError(__('Invoice not found', 'easy-invoice'));
1376 }
1377
1378 // Authorisation.
1379 //
1380 // This used to read: admins get find(), everyone else gets findPublished().
1381 // That was not a check at all — Models\Invoice::save() writes every invoice
1382 // with post_status 'publish' regardless of its workflow status, so
1383 // findPublished() returned the same record find() would have, for anyone.
1384 // This endpoint is registered for wp_ajax_nopriv, so the effective gate was
1385 // the nonce alone and any caller holding one could pull the PDF data for an
1386 // arbitrary invoice id, including drafts.
1387 //
1388 // Uses the same helper as the rest of the plugin so there is a single
1389 // definition of who may see a document: valid ?ik= token, administrator, or
1390 // the logged-in client the invoice is bound to.
1391 if (!\EasyInvoice\Controllers\InvoiceController::canSubmitPaymentForInvoice($invoice_id, $invoice)) {
1392 $this->sendError(__('You do not have permission to access this invoice', 'easy-invoice'));
1393 }
1394
1395 // Get invoice data for PDF generation
1396 $invoice_data = \EasyInvoice\Includes\Helpers\PdfHelper::getInvoiceDataForPdf($invoice);
1397
1398 // For now, return success response with invoice data
1399 // PDF generation can be implemented later with actual PDF creation
1400 $this->sendSuccess(array(
1401 'message' => __('Invoice data retrieved successfully', 'easy-invoice'),
1402 'invoice_data' => $invoice_data,
1403 'download_url' => add_query_arg(array(
1404 'action' => 'easy_invoice_generate_pdf',
1405 'invoice_id' => $invoice_id,
1406 // Bound to this invoice: an unscoped 'generate_pdf' nonce could be
1407 // taken from a document the caller may legitimately see and replayed
1408 // against any other invoice id.
1409 'nonce' => wp_create_nonce('generate_pdf_' . $invoice_id)
1410 ), admin_url('admin-ajax.php'))
1411 ));
1412 }
1413
1414 /**
1415 * Send invoice via email (public access)
1416 */
1417 public function sendInvoiceEmailPublic() {
1418 $this->verifyNonce('easy_invoice_send_invoice_email');
1419
1420 // Get invoice ID
1421 $invoice_id = isset($_POST['invoice_id']) ? intval($_POST['invoice_id']) : 0;
1422
1423 if (!$invoice_id) {
1424 $this->sendError(__('Invalid invoice ID', 'easy-invoice'));
1425 }
1426
1427 // Get invoice from repository (only published invoices for public access)
1428 $repository = InvoiceServiceProvider::getInvoiceRepository();
1429 $invoice = $repository->find($invoice_id);
1430
1431 if (!$invoice) {
1432 $this->sendError(__('Invoice not found', 'easy-invoice'));
1433 }
1434
1435 // Authorisation. The previous admin / findPublished() split was not a check:
1436 // every invoice is saved with post_status 'publish', so findPublished()
1437 // returned exactly what find() would, for any caller. This endpoint is
1438 // registered nopriv, so without this an unauthorised caller could make the
1439 // site email an arbitrary invoice out to its client. See downloadInvoicePdf().
1440 if (!\EasyInvoice\Controllers\InvoiceController::canSubmitPaymentForInvoice($invoice_id, $invoice)) {
1441 $this->sendError(__('You do not have permission to access this invoice', 'easy-invoice'));
1442 }
1443
1444 // Use EmailManager to send the email
1445 $email_manager = \EasyInvoice\Services\EmailManager::getInstance();
1446 $result = $email_manager->sendInvoiceEmail($invoice, 'new');
1447
1448 if ($result['success']) {
1449 $this->sendSuccess(array(
1450 'message' => $result['message']
1451 ));
1452 } else {
1453 $this->sendError($result['message']);
1454 }
1455 }
1456
1457 /**
1458 * Send quote via email (admin + public; guests only for published quotes).
1459 */
1460 public function sendQuoteEmailPublic() {
1461 $this->verifyNonce('easy_invoice_send_quote_email');
1462
1463 $quote_id = isset($_POST['quote_id']) ? intval($_POST['quote_id']) : 0;
1464
1465 if (!$quote_id) {
1466 $this->sendError(__('Invalid quote ID', 'easy-invoice'));
1467 }
1468
1469 $repository = \EasyInvoice\Providers\QuoteServiceProvider::getQuoteRepository();
1470 $quote = $repository->find($quote_id);
1471
1472 if (!$quote) {
1473 $this->sendError(__('Quote not found', 'easy-invoice'));
1474 }
1475
1476 // Authorisation — same reasoning as the invoice path above. Quotes are also
1477 // always stored with post_status 'publish', so findPublished() gated nothing.
1478 if (!\EasyInvoice\Controllers\QuoteController::canActOnQuote($quote_id, $quote)) {
1479 $this->sendError(__('You do not have permission to access this quote', 'easy-invoice'));
1480 }
1481
1482 $email_manager = \EasyInvoice\Services\EmailManager::getInstance();
1483 $result = $email_manager->sendQuoteEmail($quote, 'new');
1484
1485 if ($result['success']) {
1486 $quote_log_service = new \EasyInvoice\Services\QuoteLogService();
1487 $quote_log_service->logSent($quote_id, $quote->getCustomerEmail());
1488
1489 $this->sendSuccess(array(
1490 'message' => $result['message'],
1491 ));
1492 } else {
1493 $this->sendError($result['message']);
1494 }
1495 }
1496
1497 /**
1498 * Generate invoice PDF
1499 */
1500 public function generateInvoicePdf() {
1501 // Explicitly bust intermediate caching on this admin-ajax URL. Some
1502 // page-caching stacks (WP Rocket, LiteSpeed, Cloudflare full-page
1503 // cache, some CDNs) will cache a 302 Location header keyed by URL —
1504 // the URL always looks the same to the cache because both the nonce
1505 // AND the target invoice-permalink change per user, so a first-hit
1506 // response can be replayed to other users, breaking the redirect or
1507 // returning a blank body.
1508 nocache_headers();
1509 header('Cache-Control: no-store, no-cache, must-revalidate, max-age=0');
1510 header('Pragma: no-cache');
1511
1512 // Get invoice ID
1513 $invoice_id = isset($_REQUEST['invoice_id']) ? intval($_REQUEST['invoice_id']) : 0;
1514
1515 if (!$invoice_id) {
1516 $this->sendError(__('Invalid invoice ID', 'easy-invoice'));
1517 }
1518
1519 // Authorisation with graceful fallback.
1520 //
1521 // The original design gated this endpoint on a per-request WP
1522 // nonce, which is fragile in real deployments: page-caching
1523 // layers (WP Rocket, LiteSpeed, Cloudflare full-page cache)
1524 // cache the intermediate JSON response that mints the URL,
1525 // browser SameSite / ITP behaviour, and admin_url()
1526 // scheme-mismatch after login can all cause wp_verify_nonce()
1527 // to return false on the intended recipient's tab — leaving
1528 // the user stranded on this admin-ajax URL with no download.
1529 //
1530 // Accept ANY of:
1531 // 1. A valid `generate_pdf` nonce (fast path — most users,
1532 // most of the time, when the session cookie survives).
1533 // 2. An admin session (manage_options) — bypasses the nonce
1534 // because the invoice-listing button that creates this
1535 // URL is admin-only and the admin owns the request.
1536 // 3. A valid per-invoice access token (?ik=<token>) — the
1537 // same model canSubmitPaymentForInvoice uses, so emailed
1538 // invoice links can also drive a session-less download.
1539 // Only when all three paths fail do we refuse.
1540 $authorized = false;
1541
1542 // Match the same dual-key nonce lookup the removed verifyNonce()
1543 // helper did — `nonce` (client-form format used by our JS) AND
1544 // `_nonce` (standard WP form field name) — so any external
1545 // caller of this endpoint that used _nonce still works.
1546 $submitted_nonce = '';
1547 if (isset($_REQUEST['nonce'])) {
1548 $submitted_nonce = (string) $_REQUEST['nonce'];
1549 } elseif (isset($_REQUEST['_nonce'])) {
1550 $submitted_nonce = (string) $_REQUEST['_nonce'];
1551 }
1552 if ($submitted_nonce !== '' && wp_verify_nonce($submitted_nonce, 'generate_pdf_' . $invoice_id)) {
1553 $authorized = true;
1554 } elseif (current_user_can('manage_options')) {
1555 $authorized = true;
1556 } elseif (isset($_REQUEST['ik']) && is_string($_REQUEST['ik'])) {
1557 $presented = sanitize_text_field(wp_unslash($_REQUEST['ik']));
1558 $stored = (string) get_post_meta($invoice_id, '_easy_invoice_invoice_access_token', true);
1559 if ($stored !== '' && $presented !== '' && hash_equals($stored, $presented)) {
1560 $authorized = true;
1561 }
1562 }
1563
1564 if (!$authorized) {
1565 $this->sendError(__('Security check failed', 'easy-invoice'));
1566 }
1567
1568 // Get invoice from repository
1569 $repository = InvoiceServiceProvider::getInvoiceRepository();
1570
1571 // For admins, allow access to any invoice status
1572 if (current_user_can('manage_options')) {
1573 $invoice = $repository->find($invoice_id);
1574 } else {
1575 // For non-admins, only allow access to published invoices
1576 $invoice = $repository->findPublished($invoice_id);
1577 }
1578
1579 if (!$invoice) {
1580 $this->sendError(__('Invoice not found', 'easy-invoice'));
1581 }
1582
1583 // Redirect to the invoice single page with PDF generation.
1584 // Forward the ?ik= access token onwards so the single-page
1585 // template can also authorise the recipient (the same token
1586 // that got us through Path 3 above).
1587 $invoice_url = get_permalink($invoice_id);
1588 if (!$invoice_url) {
1589 $this->sendError(__('Could not generate invoice URL', 'easy-invoice'));
1590 }
1591
1592 $target_args = ['auto_download_pdf' => '1'];
1593 if (isset($_REQUEST['ik']) && is_string($_REQUEST['ik']) && $_REQUEST['ik'] !== '') {
1594 $target_args['ik'] = sanitize_text_field(wp_unslash($_REQUEST['ik']));
1595 }
1596 $target_url = add_query_arg($target_args, $invoice_url);
1597
1598 $this->redirectWithFallback($target_url);
1599 }
1600
1601 /**
1602 * Generate quote PDF
1603 */
1604 public function generateQuotePdf() {
1605 // Same cache-busting as generateInvoicePdf — see comment there.
1606 nocache_headers();
1607 header('Cache-Control: no-store, no-cache, must-revalidate, max-age=0');
1608 header('Pragma: no-cache');
1609
1610 // Get quote ID
1611 $quote_id = isset($_REQUEST['quote_id']) ? intval($_REQUEST['quote_id']) : 0;
1612
1613 if (!$quote_id) {
1614 $this->sendError(__('Invalid quote ID', 'easy-invoice'));
1615 }
1616
1617 // Authorisation with graceful fallback. Same three-path model
1618 // as generateInvoicePdf — see that method's comment for the
1619 // full rationale (nonce fragility across caching layers,
1620 // cross-tab session cookie behaviour, etc.). Paths accepted:
1621 //
1622 // 1. Valid `generate_quote_pdf` nonce (fast path).
1623 // 2. Admin session (manage_options) — the quote-listing
1624 // button that mints this URL is admin-only.
1625 // 3. Valid per-quote access token (?qk=<token>) — mirrors
1626 // the CVE-2026-9021 model so emailed quote links can
1627 // drive a session-less PDF download.
1628 $authorized = false;
1629
1630 // Same dual-key nonce lookup as the invoice handler — see
1631 // generateInvoicePdf for the backward-compat rationale.
1632 $submitted_nonce = '';
1633 if (isset($_REQUEST['nonce'])) {
1634 $submitted_nonce = (string) $_REQUEST['nonce'];
1635 } elseif (isset($_REQUEST['_nonce'])) {
1636 $submitted_nonce = (string) $_REQUEST['_nonce'];
1637 }
1638 if ($submitted_nonce !== '' && wp_verify_nonce($submitted_nonce, 'generate_quote_pdf_' . $quote_id)) {
1639 $authorized = true;
1640 } elseif (current_user_can('manage_options')) {
1641 $authorized = true;
1642 } elseif (isset($_REQUEST['qk']) && is_string($_REQUEST['qk'])) {
1643 $presented = sanitize_text_field(wp_unslash($_REQUEST['qk']));
1644 $stored = (string) get_post_meta($quote_id, '_easy_invoice_quote_access_token', true);
1645 if ($stored !== '' && $presented !== '' && hash_equals($stored, $presented)) {
1646 $authorized = true;
1647 }
1648 }
1649
1650 if (!$authorized) {
1651 $this->sendError(__('Security check failed', 'easy-invoice'));
1652 }
1653
1654 // Get quote from repository — mirror invoice PDF: only published quotes for non-admins (incl. nopriv).
1655 $repository = \EasyInvoice\Providers\QuoteServiceProvider::getQuoteRepository();
1656 if (current_user_can('manage_options')) {
1657 $quote = $repository->find($quote_id);
1658 } else {
1659 $quote = $repository->findPublished($quote_id);
1660 }
1661
1662 if (!$quote) {
1663 $this->sendError(__('Quote not found', 'easy-invoice'));
1664 }
1665
1666 // Redirect to the quote single page with PDF generation.
1667 // Forward the ?qk= access token so the single-page template
1668 // can also authorise the recipient with the same key that
1669 // got us through Path 3.
1670 $quote_url = get_permalink($quote_id);
1671 if (!$quote_url) {
1672 $this->sendError(__('Could not generate quote URL', 'easy-invoice'));
1673 }
1674
1675 $target_args = ['auto_download_pdf' => '1'];
1676 if (isset($_REQUEST['qk']) && is_string($_REQUEST['qk']) && $_REQUEST['qk'] !== '') {
1677 $target_args['qk'] = sanitize_text_field(wp_unslash($_REQUEST['qk']));
1678 }
1679 $target_url = add_query_arg($target_args, $quote_url);
1680
1681 $this->redirectWithFallback($target_url);
1682 }
1683
1684 /**
1685 * Redirect the current request to $url, with a client-side fallback
1686 * when the server-side redirect can't fire.
1687 *
1688 * `wp_safe_redirect()` silently no-ops if headers have already been sent
1689 * (BOM in a plugin file, plugin echoing during an action, PHP warning
1690 * output, etc.). Because we also `exit;` immediately after, that failure
1691 * mode produces a 200 OK with an empty body — the reported blank-page
1692 * bug on the invoice-listing PDF download.
1693 *
1694 * This helper detects the headers-sent case and emits a minimal HTML
1695 * document that redirects via meta-refresh (works with JS disabled) and
1696 * `window.location.replace()` (JS-enabled, doesn't add a history entry).
1697 * Both point at the same escaped URL so misconfigured stacks still get
1698 * the user to the target page.
1699 */
1700 private function redirectWithFallback(string $url): void {
1701 // Suppress cache one more time in case some plugin filtered our
1702 // earlier headers away between then and now.
1703 nocache_headers();
1704
1705 if (!headers_sent()) {
1706 wp_safe_redirect($url);
1707 exit;
1708 }
1709
1710 // Fallback: server-side redirect impossible. Emit a client-side one.
1711 $safe_url = esc_url_raw($url);
1712 echo '<!DOCTYPE html><html lang="en"><head><meta charset="UTF-8">';
1713 echo '<meta http-equiv="refresh" content="0; url=' . esc_attr($safe_url) . '">';
1714 echo '<title>Redirecting…</title>';
1715 echo '<script>window.location.replace(' . wp_json_encode($safe_url) . ');</script>';
1716 echo '</head><body>';
1717 echo '<p>Redirecting to <a href="' . esc_url($safe_url) . '">' . esc_html($safe_url) . '</a>…</p>';
1718 echo '</body></html>';
1719 exit;
1720 }
1721
1722 /**
1723 * Search clients for the dropdown
1724 */
1725 public function searchClients() {
1726 $this->verifyNonce('easy_invoice_nonce');
1727
1728 if (!easy_invoice_user_can('ei_view_clients')) {
1729 $this->sendError(__('You do not have permission to perform this action', 'easy-invoice'));
1730 }
1731
1732 $query = isset($_POST['query']) ? sanitize_text_field($_POST['query']) : '';
1733
1734 // Get client repository
1735 $client_repository = \EasyInvoice\Providers\ClientServiceProvider::getClientRepository();
1736
1737 // Search clients
1738 $clients = $client_repository->search($query);
1739
1740 // Row-level security: restrict to assigned clients for Sales reps
1741 // (users with ei_view_clients but no ei_view_all_clients). Null
1742 // return = unrestricted, no-op.
1743 if (function_exists('easy_invoice_visible_client_ids')) {
1744 $visible = easy_invoice_visible_client_ids();
1745 if (is_array($visible)) {
1746 $allowed = array_flip(array_map('intval', $visible));
1747 $clients = array_values(array_filter($clients, static function ($c) use ($allowed) {
1748 return isset($allowed[(int) $c->getId()]);
1749 }));
1750 }
1751 }
1752
1753 // Bypass $this->sendSuccess() — search is a read endpoint and
1754 // shouldn't show "Operation completed successfully" toasts on
1755 // every keystroke. Use wp_send_json_success directly.
1756 if (empty($clients)) {
1757 wp_send_json_success(array());
1758 }
1759
1760 // Format clients for dropdown
1761 $formatted_clients = array();
1762 foreach ($clients as $client) {
1763 // Get the WordPress user data directly
1764 $user = get_user_by('id', $client->getId());
1765 if (!$user) {
1766 continue;
1767 }
1768
1769 // Use Client model properties first, fallback to WordPress user fields
1770 $business_name = $client->business_client_name ?: '';
1771 $first_name = $client->first_name ?: $user->first_name ?: '';
1772 $last_name = $client->last_name ?: $user->last_name ?: '';
1773 $email = $client->email ?: $user->user_email ?: '';
1774
1775
1776
1777 // Create display name
1778 $client_name = $business_name ?: ($first_name . ' ' . $last_name);
1779 if (empty(trim($client_name))) {
1780 $client_name = $user->display_name ?: 'User ' . $client->getId();
1781 }
1782
1783 // Include all clients, even those with empty emails
1784 $formatted_clients[] = array(
1785 'id' => $client->getId(),
1786 'name' => $client_name,
1787 'email' => $email,
1788 'company' => $business_name,
1789 'phone' => $client->phone ?: '',
1790 'address' => $client->address ?: '',
1791 'website' => $client->website ?: '',
1792 'display_name' => $client_name . ' (' . $email . ')'
1793 );
1794 }
1795
1796 wp_send_json_success($formatted_clients);
1797 }
1798
1799 /**
1800 * Save additional CSS for invoice/quote
1801 */
1802 public function saveAdditionalCSS() {
1803 // Verify nonce
1804 if (!wp_verify_nonce(($_POST['nonce'] ?? ''), 'save_additional_css_nonce')) {
1805 $this->sendError('Security check failed');
1806 return;
1807 }
1808
1809 // Check user capabilities - require administrator
1810 if (!current_user_can('manage_options')) {
1811 $this->sendError('You do not have permission to perform this action');
1812 return;
1813 }
1814
1815 // Validate and sanitize post ID
1816 $post_id = isset($_POST['post_id']) ? intval($_POST['post_id']) : 0;
1817 if ($post_id <= 0) {
1818 $this->sendError('Invalid post ID');
1819 return;
1820 }
1821
1822 // Verify post exists and user can edit it
1823 $post = get_post($post_id);
1824 if (!$post || !current_user_can('edit_post', $post_id)) {
1825 $this->sendError('You cannot edit this post');
1826 return;
1827 }
1828
1829 // Verify post type is invoice or quote
1830 $valid_post_types = [
1831 \EasyInvoice\Constants\PostTypes::EASY_INVOICE_POST_TYPE,
1832 \EasyInvoice\Constants\PostTypes::EASY_INVOICE_QUOTE_POST_TYPE
1833 ];
1834 if (!in_array($post->post_type, $valid_post_types)) {
1835 $this->sendError('Invalid post type');
1836 return;
1837 }
1838
1839 // Get and sanitize CSS content
1840 $css = isset($_POST['css']) ? $_POST['css'] : '';
1841
1842 // Enhanced CSS sanitization
1843 $css = $this->sanitizeCSS($css);
1844
1845 // Limit CSS length to prevent abuse
1846 if (strlen($css) > 50000) { // 50KB limit
1847 $this->sendError('CSS content too long');
1848 return;
1849 }
1850
1851 // Save CSS to post meta
1852 $result = update_post_meta($post_id, '_easy_invoice_additional_css', $css);
1853
1854 if ($result !== false) {
1855 $this->sendSuccess(array(
1856 'message' => 'CSS saved successfully',
1857 'css' => $css,
1858 'post_id' => $post_id
1859 ));
1860 } else {
1861 $this->sendError('Failed to save CSS');
1862 }
1863 }
1864
1865 /**
1866 * Enhanced CSS sanitization - preserves valid CSS while removing threats
1867 */
1868 private function sanitizeCSS($css) {
1869 // Remove PHP tags first
1870 $css = preg_replace('/<\?php.*?\?>/is', '', $css);
1871
1872 // Remove HTML tags (script, iframe, object, embed)
1873 $css = preg_replace('/<script[^>]*>.*?<\/script>/is', '', $css);
1874 $css = preg_replace('/<iframe[^>]*>.*?<\/iframe>/is', '', $css);
1875 $css = preg_replace('/<object[^>]*>.*?<\/object>/is', '', $css);
1876 $css = preg_replace('/<embed[^>]*>/is', '', $css);
1877
1878 // Remove dangerous CSS constructs
1879 $css = preg_replace('/expression\s*\(/i', '', $css); // CSS expressions
1880 $css = preg_replace('/javascript\s*:/i', '', $css); // JavaScript protocol
1881 $css = preg_replace('/@import\s+url\s*\(/i', '', $css); // @import url()
1882 $css = preg_replace('/@import\s+["\'][^"\']+["\']/', '', $css); // @import with quotes
1883 $css = preg_replace('/behavior\s*:\s*url\s*\(/i', '', $css); // IE behavior
1884 $css = preg_replace('/binding\s*:/i', '', $css); // XBL binding
1885
1886 // Remove dangerous CSS functions (but keep safe ones)
1887 $dangerous_functions = ['eval', 'exec', 'system', 'passthru', 'shell_exec', 'phpinfo', 'file_get_contents', 'file_put_contents', 'fopen', 'fwrite', 'curl_exec'];
1888 foreach ($dangerous_functions as $func) {
1889 $css = preg_replace('/\b' . preg_quote($func, '/') . '\s*\(/i', '', $css);
1890 }
1891
1892 // Remove data URLs that could contain malicious content
1893 $css = preg_replace('/data\s*:\s*["\'][^"\']*["\']/i', '', $css);
1894
1895 // Remove vbscript: protocol
1896 $css = preg_replace('/vbscript\s*:/i', '', $css);
1897
1898 // Remove any remaining HTML-like constructs
1899 $css = htmlspecialchars_decode($css, ENT_QUOTES);
1900
1901 // Basic cleanup - remove excessive whitespace but preserve CSS structure
1902 $css = preg_replace('/\s+/', ' ', $css);
1903 $css = preg_replace('/;\s*}/', '}', $css);
1904 $css = preg_replace('/\s*{\s*/', ' {', $css);
1905 $css = preg_replace('/;\s*;/', ';', $css);
1906
1907 return trim($css);
1908 }
1909 }
1910