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
← All changes | includes/Admin/EasyInvoiceAjax.php +829 -367 2.1.12.4.0 View file →
@@ -15,9 +15,9 @@
15 15 use EasyInvoice\Constants\ClientFields;
16 16
17 17 /**
18 18 * EasyInvoiceAjax Class
19 - *
19 + *
20 20 * Handles all AJAX functionality for the plugin.
21 21 */
22 22 class EasyInvoiceAjax {
23 23 /**
@@ -24,39 +24,42 @@
24 24 * Initialize AJAX handlers
25 25 */
26 26 public function init() {
27 27 // Invoice actions
28 - //add_action('wp_ajax_easy_invoice_save', array($this, 'saveInvoice'));
29 28 add_action('wp_ajax_easy_invoice_delete', array($this, 'deleteInvoice'));
30 29 add_action('wp_ajax_easy_invoice_get', array($this, 'getInvoice'));
31 30 add_action('wp_ajax_easy_invoice_save_invoice', array($this, 'saveInvoice'));
32 31 add_action('wp_ajax_easy_invoice_save_and_send_invoice', array($this, 'saveAndSendInvoice'));
33 -
32 +
34 33 // Template actions
35 34
36 35
37 -
36 +
38 37 // Document and email actions
39 38 add_action('wp_ajax_easy_invoice_download_pdf', array($this, 'downloadPdf'));
40 39 add_action('wp_ajax_easy_invoice_send_email', array($this, 'sendInvoiceEmail'));
41 -
40 +
42 41 // Single page actions (for public access)
43 42 add_action('wp_ajax_easy_invoice_download_invoice_pdf', array($this, 'downloadInvoicePdf'));
44 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 45 add_action('wp_ajax_nopriv_easy_invoice_download_invoice_pdf', array($this, 'downloadInvoicePdf'));
46 46 add_action('wp_ajax_nopriv_easy_invoice_send_invoice_email', array($this, 'sendInvoiceEmailPublic'));
47 -
47 + add_action('wp_ajax_nopriv_easy_invoice_send_quote_email', array($this, 'sendQuoteEmailPublic'));
48 +
48 49 // PDF generation actions
49 50 add_action('wp_ajax_easy_invoice_generate_pdf', array($this, 'generateInvoicePdf'));
50 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'));
51 55 add_action('wp_ajax_nopriv_easy_invoice_generate_pdf', array($this, 'generateInvoicePdf'));
52 56 add_action('wp_ajax_nopriv_easy_invoice_generate_quote_pdf', array($this, 'generateQuotePdf'));
53 -
57 +
54 58 // Quote document actions
55 59 add_action('wp_ajax_easy_invoice_download_quote_pdf', array($this, 'downloadQuotePdf'));
56 - add_action('wp_ajax_nopriv_easy_invoice_download_quote_pdf', array($this, 'downloadQuotePdf'));
57 60 add_action('wp_ajax_easy_invoice_save_quote', array($this, 'saveQuote'));
58 -
61 +
59 62 // Client actions
60 63 add_action('wp_ajax_easy_invoice_save_client', array($this, 'saveClient'));
61 64 add_action('wp_ajax_easy_invoice_delete_client', array($this, 'deleteClient'));
62 65 add_action('wp_ajax_easy_invoice_get_client', array($this, 'getClient'));
@@ -66,37 +69,48 @@
66 69 add_action('wp_ajax_easy_invoice_generate_password', array($this, 'generatePassword'));
67 70 add_action('wp_ajax_easy_invoice_search_clients', array($this, 'searchClients'));
68 71 add_action('wp_ajax_easy_invoice_update_invoices_data', array($this, 'updateInvoicesData'));
69 72 }
70 -
73 +
71 74 /**
72 75 * Save invoice
73 76 */
74 77 public function saveInvoice() {
75 78 $this->verifyNonce('easy_invoice_nonce');
76 -
77 - if (!current_user_can('manage_options')) {
79 +
80 + if (!easy_invoice_user_can('ei_create_invoice')) {
78 81 $this->sendError(__('You do not have permission to perform this action', 'easy-invoice'));
79 82 }
80 -
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 +
81 94 // Get the raw invoice data from the form
82 95 $raw_invoice_data = isset($_POST['invoice_data']) ? $_POST['invoice_data'] : $_POST;
83 -
96 +
84 97 // Remove non-invoice fields
85 98 unset($raw_invoice_data['action']);
86 99 unset($raw_invoice_data['nonce']);
87 -
100 +
88 101 // Process the invoice data
89 102 $invoice_form_manager = new \EasyInvoice\Forms\Invoice\InvoiceFormManager();
90 103 $invoice_data = $invoice_form_manager->processFormData($raw_invoice_data);
91 -
104 +
92 105 if (!empty($invoice_data['errors'])) {
106 + // The toast is what the user sees; the field may sit on another tab.
93 107 wp_send_json_error([
94 - 'message' => 'Validation failed',
108 + 'message' => implode(' ', array_map('strval', $invoice_data['errors'])),
95 109 'errors' => $invoice_data['errors']
96 110 ]);
97 111 }
98 -
112 +
99 113 // Handle items separately - process the natural form submission format
100 114 if (isset($raw_invoice_data['items']) && is_array($raw_invoice_data['items'])) {
101 115 // Form submits items as items[0][title], items[0][description], etc.
102 116 // Convert to array of item objects for processing
@@ -105,28 +119,28 @@
105 119 if (is_array($item_data)) {
106 120 $items_array[] = $item_data;
107 121 }
108 122 }
109 -
123 +
110 124 // Process items using the dynamic field system
111 125 $invoice_data['data']['items'] = $invoice_form_manager->processItemsData($items_array);
112 126 }
113 -
127 +
114 128 // Handle special fields that might not be in the form definition
115 129 if (isset($raw_invoice_data['invoice_id'])) {
116 130 $invoice_data['data']['invoice_id'] = intval($raw_invoice_data['invoice_id']);
117 131 }
118 -
132 +
119 133 if (isset($raw_invoice_data['client_id'])) {
120 134 $invoice_data['data']['client_id'] = intval($raw_invoice_data['client_id']);
121 135 }
122 -
123 136
124 -
137 +
138 +
125 139 $invoice_id = isset($invoice_data['data']['invoice_id']) ? intval($invoice_data['data']['invoice_id']) : 0;
126 -
140 +
127 141 $repository = InvoiceServiceProvider::getInvoiceRepository();
128 -
142 +
129 143 if ($invoice_id > 0) {
130 144 // Update existing invoice - preserve existing invoice number
131 145 unset($invoice_data['data']['invoice_number']);
132 146 unset($invoice_data['data']['number']);
@@ -131,45 +145,60 @@
131 145 unset($invoice_data['data']['invoice_number']);
132 146 unset($invoice_data['data']['number']);
133 147
134 148 $invoice = $repository->update($invoice_id, $invoice_data['data']);
135 -
149 +
136 150 if (!$invoice) {
137 -
151 +
138 152 $this->sendError(__('Failed to update invoice', 'easy-invoice'));
139 153 }
140 -
154 +
141 155 // Use FormProcessor to save form data to database
142 156 $form_processor = new \EasyInvoice\Forms\FormProcessor();
143 157 $all_fields = $invoice_form_manager->getAllFields();
144 158 $form_processor->saveFormDataToDatabase($invoice_data['data'], $all_fields, $invoice);
145 -
159 +
146 160 $message = __('Invoice updated successfully', 'easy-invoice');
147 161 } else {
148 162 // Create new invoice - allow auto-generated invoice number to be saved
149 163 // The invoice number will be auto-generated by the form and included in the data
150 -
164 +
151 165 $invoice = $repository->create($invoice_data['data']);
152 -
153 -
166 +
167 +
154 168 if (!$invoice) {
155 169 $this->sendError(__('Failed to create invoice', 'easy-invoice'));
156 170 }
157 -
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 +
158 181 // Use FormProcessor to save form data to database
159 182 $form_processor = new \EasyInvoice\Forms\FormProcessor();
160 183 $all_fields = $invoice_form_manager->getAllFields();
161 184 $form_processor->saveFormDataToDatabase($invoice_data['data'], $all_fields, $invoice);
162 -
185 +
163 186 $invoice_id = $invoice->getId();
164 187 $message = __('Invoice created successfully', 'easy-invoice');
165 188 }
166 -
189 +
167 190 // Handle items
168 191 if (isset($invoice_data['data']['items']) && is_array($invoice_data['data']['items'])) {
169 192 $invoice->setItems($invoice_data['data']['items']);
170 193 }
171 -
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 +
172 201 // Prepare response data
173 202 $response_data = array(
174 203 'invoice_id' => $invoice_id,
175 204 'invoice' => $invoice->toArray(),
@@ -178,9 +207,9 @@
178 207 'message' => $message,
179 208 'options' => array('duration' => 4000)
180 209 )
181 210 );
182 -
211 +
183 212 // Include client data if invoice has a client
184 213 if ($invoice->getClientId()) {
185 214 $client_repository = ClientServiceProvider::getClientRepository();
186 215 $client = $client_repository->find($invoice->getClientId());
@@ -195,33 +224,34 @@
195 224 'website' => $client->getWebsite() ?: '',
196 225 );
197 226 }
198 227 }
199 -
228 +
200 229 wp_send_json_success($response_data);
201 230 }
202 -
231 +
203 232 /**
204 233 * Save and send invoice
205 234 */
206 235 public function saveAndSendInvoice() {
207 236 $this->verifyNonce('easy_invoice_nonce');
208 -
209 - if (!current_user_can('manage_options')) {
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')) {
210 240 $this->sendError(__('You do not have permission to perform this action', 'easy-invoice'));
211 241 }
212 -
242 +
213 243 // First save the invoice
214 244 $this->saveInvoice();
215 -
245 +
216 246 // If we get here, the invoice was saved successfully
217 247 // Now send the invoice via email
218 248 $invoice_id = isset($_POST['invoice_data']['invoice_id']) ? intval($_POST['invoice_data']['invoice_id']) : 0;
219 -
249 +
220 250 if ($invoice_id > 0) {
221 251 // Send the invoice via email
222 252 $result = $this->sendInvoiceEmail($invoice_id);
223 -
253 +
224 254 if ($result['success']) {
225 255 $this->sendSuccess(array(
226 256 'message' => __('Invoice saved and sent successfully', 'easy-invoice'),
227 257 'invoice_id' => $invoice_id
@@ -232,106 +262,106 @@
232 262 } else {
233 263 $this->sendError(__('Invalid invoice ID for sending', 'easy-invoice'));
234 264 }
235 265 }
236 -
266 +
237 267 /**
238 268 * Delete invoice
239 269 */
240 270 public function deleteInvoice() {
241 271 $this->verifyNonce('easy_invoice_nonce');
242 -
243 - if (!current_user_can('manage_options')) {
272 +
273 + if (!easy_invoice_user_can('ei_delete_invoice')) {
244 274 $this->sendError(__('You do not have permission to perform this action', 'easy-invoice'));
245 275 }
246 -
276 +
247 277 $invoice_id = isset($_POST['invoice_id']) ? intval($_POST['invoice_id']) : 0;
248 -
278 +
249 279 if ($invoice_id <= 0) {
250 280 $this->sendError(__('Invalid invoice ID', 'easy-invoice'));
251 281 }
252 -
282 +
253 283 $repository = InvoiceServiceProvider::getInvoiceRepository();
254 284 $result = $repository->delete($invoice_id);
255 -
285 +
256 286 if (!$result) {
257 287 $this->sendError(__('Failed to delete invoice', 'easy-invoice'));
258 288 }
259 -
289 +
260 290 $this->sendSuccess(array(
261 291 'message' => __('Invoice deleted successfully', 'easy-invoice'),
262 292 'invoice_id' => $invoice_id,
263 293 ));
264 294 }
265 -
295 +
266 296 /**
267 297 * Get invoice
268 298 */
269 299 public function getInvoice() {
270 300 $this->verifyNonce('easy_invoice_nonce');
271 -
272 - if (!current_user_can('manage_options')) {
301 +
302 + if (!easy_invoice_user_can('ei_view_invoices')) {
273 303 $this->sendError(__('You do not have permission to perform this action', 'easy-invoice'));
274 304 }
275 -
305 +
276 306 $invoice_id = isset($_REQUEST['invoice_id']) ? intval($_REQUEST['invoice_id']) : 0;
277 -
307 +
278 308 if ($invoice_id <= 0) {
279 309 $this->sendError(__('Invalid invoice ID', 'easy-invoice'));
280 310 }
281 -
311 +
282 312 $repository = InvoiceServiceProvider::getInvoiceRepository();
283 313 $invoice = $repository->find($invoice_id);
284 -
314 +
285 315 if (!$invoice) {
286 316 $this->sendError(__('Invoice not found', 'easy-invoice'));
287 317 }
288 -
318 +
289 319 $this->sendSuccess(array(
290 320 'invoice' => $invoice->toArray(),
291 321 ));
292 322 }
293 -
323 +
294 324 /**
295 325 * Save client
296 326 */
297 327 public function saveClient() {
298 328 $this->verifyNonce('easy_invoice_nonce');
299 -
300 - if (!current_user_can('manage_options')) {
329 +
330 + if (!easy_invoice_user_can('ei_manage_clients')) {
301 331 $this->sendError(__('You do not have permission to perform this action', 'easy-invoice'));
302 332 }
303 -
333 +
304 334 $client_id = isset($_POST['client_id']) ? intval($_POST['client_id']) : 0;
305 335 $client_data = isset($_POST['client_data']) ? $this->sanitizeData($_POST['client_data']) : array();
306 -
336 +
307 337 if (empty($client_data)) {
308 338 $this->sendError(__('Invalid client data', 'easy-invoice'));
309 339 }
310 -
340 +
311 341 $repository = ClientServiceProvider::getClientRepository();
312 -
342 +
313 343 if ($client_id > 0) {
314 344 // Update existing client
315 345 $client = $repository->update($client_id, $client_data);
316 -
346 +
317 347 if (!$client) {
318 348 $this->sendError(__('Failed to update client', 'easy-invoice'));
319 349 }
320 -
350 +
321 351 $message = __('Client updated successfully', 'easy-invoice');
322 352 } else {
323 353 // Create new client
324 354 $client = $repository->create($client_data);
325 -
355 +
326 356 if (!$client) {
327 357 $this->sendError(__('Failed to create client', 'easy-invoice'));
328 358 }
329 -
359 +
330 360 $client_id = $client->getId();
331 361 $message = __('Client created successfully', 'easy-invoice');
332 362 }
333 -
363 +
334 364 $this->sendSuccess(array(
335 365 'message' => $message,
336 366 'client_id' => $client_id,
337 367 'client' => $client->toArray(),
@@ -336,9 +366,9 @@
336 366 'client_id' => $client_id,
337 367 'client' => $client->toArray(),
338 368 ));
339 369 }
340 -
370 +
341 371 /**
342 372 * Delete client
343 373 */
344 374 public function deleteClient() {
@@ -343,55 +373,65 @@
343 373 */
344 374 public function deleteClient() {
345 375 try {
346 376 $this->verifyNonce('easy_invoice_nonce');
347 -
348 - if (!current_user_can('manage_options')) {
377 +
378 + if (!easy_invoice_user_can('ei_manage_clients')) {
349 379 $this->sendError(__('You do not have permission to perform this action', 'easy-invoice'));
350 380 }
351 -
381 +
352 382 $client_id = isset($_POST['client_id']) ? intval($_POST['client_id']) : 0;
353 - $delete_associated_documents = isset($_POST['delete_associated_documents']) ? (bool)$_POST['delete_associated_documents'] : false;
354 -
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 +
355 388 if ($client_id <= 0) {
356 389 $this->sendError(__('Invalid client ID', 'easy-invoice'));
357 390 }
358 -
391 +
359 392 // Check if the user exists and is not an administrator
360 393 $user = get_user_by('ID', $client_id);
361 394 if (!$user) {
362 395 $this->sendError(__('User not found', 'easy-invoice'));
363 396 }
364 -
397 +
365 398 if (in_array('administrator', $user->roles)) {
366 399 $this->sendError(__('Cannot delete administrator accounts', 'easy-invoice'));
367 400 }
368 -
401 +
369 402 global $wpdb;
370 -
403 +
371 404 // Get counts of associated documents
372 405 $invoice_count = $wpdb->get_var($wpdb->prepare(
373 406 "SELECT COUNT(*) FROM {$wpdb->postmeta} WHERE meta_key = '_easy_invoice_client_id' AND meta_value = %d",
374 407 $client_id
375 408 ));
376 -
409 +
377 410 $quote_count = $wpdb->get_var($wpdb->prepare(
378 411 "SELECT COUNT(*) FROM {$wpdb->postmeta} WHERE meta_key = '_easy_invoice_quote_client_id' AND meta_value = %d",
379 412 $client_id
380 413 ));
381 -
382 - $payment_count = $wpdb->get_var($wpdb->prepare(
383 - "SELECT COUNT(*) FROM {$wpdb->postmeta} WHERE meta_key = '_easy_payment_client_id' AND meta_value = %d",
384 - $client_id
385 - ));
386 -
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 +
387 422 $total_documents = $invoice_count + $quote_count + $payment_count;
388 -
423 +
389 424 if ($delete_associated_documents) {
390 425 // Delete all associated documents
391 - $this->log(sprintf('Deleting client %d with all associated documents (%d invoices, %d quotes, %d payments)',
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)',
392 432 $client_id, $invoice_count, $quote_count, $payment_count));
393 -
433 +
394 434 // Delete invoices
395 435 if ($invoice_count > 0) {
396 436 $invoices = $wpdb->get_col($wpdb->prepare(
397 437 "SELECT post_id FROM {$wpdb->postmeta} WHERE meta_key = '_easy_invoice_client_id' AND meta_value = %d",
@@ -400,9 +440,9 @@
400 440 foreach ($invoices as $invoice_id) {
401 441 wp_delete_post($invoice_id, true);
402 442 }
403 443 }
404 -
444 +
405 445 // Delete quotes
406 446 if ($quote_count > 0) {
407 447 $quotes = $wpdb->get_col($wpdb->prepare(
408 448 "SELECT post_id FROM {$wpdb->postmeta} WHERE meta_key = '_easy_invoice_quote_client_id' AND meta_value = %d",
@@ -411,26 +451,60 @@
411 451 foreach ($quotes as $quote_id) {
412 452 wp_delete_post($quote_id, true);
413 453 }
414 454 }
415 -
455 +
416 456 // Delete payments
417 - if ($payment_count > 0) {
418 - $payments = $wpdb->get_col($wpdb->prepare(
419 - "SELECT post_id FROM {$wpdb->postmeta} WHERE meta_key = '_easy_invoice_payment_client_id' AND meta_value = %d",
420 - $client_id
421 - ));
422 - foreach ($payments as $payment_id) {
423 - wp_delete_post($payment_id, true);
424 - }
425 - }
426 -
427 - $message = sprintf(__('Client and all associated documents (%d total) deleted successfully', 'easy-invoice'), $total_documents);
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 + );
428 501 } else {
429 502 // Only remove client associations, preserve documents
430 - $this->log(sprintf('Removing client associations for client %d (%d invoices, %d quotes, %d payments)',
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)',
431 505 $client_id, $invoice_count, $quote_count, $payment_count));
432 -
506 +
433 507 // Remove client associations from invoices
434 508 if ($invoice_count > 0) {
435 509 $wpdb->delete(
436 510 $wpdb->postmeta,
@@ -436,9 +510,9 @@
436 510 $wpdb->postmeta,
437 511 ['meta_key' => '_easy_invoice_client_id', 'meta_value' => $client_id]
438 512 );
439 513 }
440 -
514 +
441 515 // Remove client associations from quotes
442 516 if ($quote_count > 0) {
443 517 $wpdb->delete(
444 518 $wpdb->postmeta,
@@ -444,9 +518,9 @@
444 518 $wpdb->postmeta,
445 519 ['meta_key' => '_easy_invoice_quote_client_id', 'meta_value' => $client_id]
446 520 );
447 521 }
448 -
522 +
449 523 // Remove client associations from payments
450 524 if ($payment_count > 0) {
451 525 $wpdb->delete(
452 526 $wpdb->postmeta,
@@ -452,20 +526,38 @@
452 526 $wpdb->postmeta,
453 527 ['meta_key' => '_easy_payment_client_id', 'meta_value' => $client_id]
454 528 );
455 529 }
456 -
530 +
531 + /* translators: %d: number of documents. */
457 532 $message = sprintf(__('Client deleted successfully. %d documents preserved but client associations removed.', 'easy-invoice'), $total_documents);
458 533 }
459 -
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 +
460 540 // Delete the WordPress user
461 541 require_once(ABSPATH . 'wp-admin/includes/user.php');
462 542 $result = wp_delete_user($client_id);
463 -
543 +
464 544 if (!$result) {
465 545 $this->sendError(__('Failed to delete client', 'easy-invoice'));
466 546 }
467 -
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 +
468 560 $this->sendSuccess(array(
469 561 'message' => $message,
470 562 'client_id' => $client_id,
471 563 'documents_deleted' => $delete_associated_documents,
@@ -470,39 +562,39 @@
470 562 'client_id' => $client_id,
471 563 'documents_deleted' => $delete_associated_documents,
472 564 'total_documents' => $total_documents
473 565 ));
474 -
566 +
475 567 } catch (\Exception $e) {
476 568 $this->sendError($e->getMessage());
477 569 }
478 570 }
479 -
571 +
480 572 /**
481 573 * Get client
482 574 */
483 575 public function getClient() {
484 576 $this->verifyNonce('easy_invoice_nonce');
485 -
486 - if (!current_user_can('manage_options')) {
577 +
578 + if (!easy_invoice_user_can('ei_view_clients')) {
487 579 $this->sendError(__('You do not have permission to perform this action', 'easy-invoice'));
488 580 }
489 -
581 +
490 582 $client_id = isset($_REQUEST['client_id']) ? intval($_REQUEST['client_id']) : 0;
491 -
583 +
492 584 if ($client_id <= 0) {
493 585 $this->sendError(__('Invalid client ID', 'easy-invoice'));
494 586 }
495 -
587 +
496 588 $repository = ClientServiceProvider::getClientRepository();
497 589 $client = $repository->find($client_id);
498 -
590 +
499 591 if (!$client) {
500 592 $this->sendError(__('Client not found', 'easy-invoice'));
501 593 }
502 -
594 +
503 595 $client_data = $client->toArray();
504 -
596 +
505 597 // Return comprehensive client data in a unified format that works for both form population and display
506 598 $this->sendSuccess(array(
507 599 // Form population fields (for invoice-builder.js and invoice-form.js)
508 600 'name' => $client_data['company_name'] ?? $client_data['contact_name'] ?? '',
@@ -510,9 +602,9 @@
510 602 'phone' => $client_data['phone'] ?? '',
511 603 'company' => $client_data['company_name'] ?? '',
512 604 'address' => $client_data['billing_address'] ?? '',
513 605 'website' => $client_data['website'] ?? '',
514 -
606 +
515 607 // Display fields (for client-manager.js)
516 608 'business_client_name' => $client->getBusinessClientName(),
517 609 'username' => $client->getUsername(),
518 610 'extra_info' => $client->getExtraInfo(),
@@ -517,9 +609,9 @@
517 609 'username' => $client->getUsername(),
518 610 'extra_info' => $client->getExtraInfo(),
519 611 'first_name' => $client->getFirstName(),
520 612 'last_name' => $client->getLastName(),
521 -
613 +
522 614 // Raw data for backward compatibility
523 615 'client' => array(
524 616 'name' => $client_data['company_name'] ?? $client_data['contact_name'] ?? '',
525 617 'email' => $client_data['email'] ?? '',
@@ -529,12 +621,12 @@
529 621 'website' => $client_data['website'] ?? '',
530 622 )
531 623 ));
532 624 }
533 -
625 +
534 626 /**
535 627 * Verify nonce
536 - *
628 + *
537 629 * @param string $action The nonce action
538 630 */
539 631 private function verifyNonce($action) {
540 632 // Check for _nonce (standard format) first
@@ -540,21 +632,21 @@
540 632 // Check for _nonce (standard format) first
541 633 if (isset($_REQUEST['_nonce']) && wp_verify_nonce($_REQUEST['_nonce'], $action)) {
542 634 return;
543 635 }
544 -
636 +
545 637 // Also check for 'nonce' (client form format)
546 638 if (isset($_REQUEST['nonce']) && wp_verify_nonce($_REQUEST['nonce'], $action)) {
547 639 return;
548 640 }
549 -
641 +
550 642 // If we get here, neither nonce format was valid
551 643 $this->sendError(__('Security check failed', 'easy-invoice'));
552 644 }
553 -
645 +
554 646 /**
555 647 * Sanitize data
556 - *
648 + *
557 649 * @param array $data The data to sanitize
558 650 * @return array The sanitized data
559 651 */
560 652 private function sanitizeData($data) {
@@ -560,22 +652,22 @@
560 652 private function sanitizeData($data) {
561 653 if (!is_array($data)) {
562 654 return array();
563 655 }
564 -
656 +
565 657 $sanitized = array();
566 -
658 +
567 659 // Define fields that should allow HTML (like textarea content)
568 660 $html_fields = [
569 - 'invoice_description', 'description', 'notes', 'terms',
661 + 'invoice_description', 'description', 'notes', 'terms',
570 662 'internal_notes', 'customer_address'
571 663 ];
572 -
664 +
573 665 // Define numeric fields
574 666 $numeric_fields = [
575 667 'invoice_id', 'client_id', 'discount_value', 'tax_rate'
576 668 ];
577 -
669 +
578 670 foreach ($data as $key => $value) {
579 671 if (is_array($value)) {
580 672 $sanitized[$key] = $this->sanitizeData($value);
581 673 } else if (in_array($key, $html_fields)) {
@@ -587,12 +679,12 @@
587 679 } else {
588 680 $sanitized[$key] = sanitize_text_field($value);
589 681 }
590 682 }
591 -
683 +
592 684 return $sanitized;
593 685 }
594 -
686 +
595 687 /**
596 688 * Send success response
597 689 */
598 690 private function sendSuccess($data = array()) {
@@ -597,11 +689,22 @@
597 689 */
598 690 private function sendSuccess($data = array()) {
599 691 // Check if we should suppress global toast
600 692 $suppress_toast = isset($_POST['suppress_global_toast']) && $_POST['suppress_global_toast'] === 'true';
601 -
602 - // Add toast notification if not already present and not suppressed
603 - if (!isset($data['toast']) && !$suppress_toast) {
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) {
604 707 $message = isset($data['message']) ? $data['message'] : __('Operation completed successfully', 'easy-invoice');
605 708 $data['toast'] = array(
606 709 'type' => 'success',
607 710 'message' => $message,
@@ -607,17 +710,17 @@
607 710 'message' => $message,
608 711 'options' => array('duration' => 4000)
609 712 );
610 713 }
611 -
714 +
612 715 // Remove toast data if suppressed
613 - if ($suppress_toast && isset($data['toast'])) {
716 + if ($is_assoc_or_empty && $suppress_toast && isset($data['toast'])) {
614 717 unset($data['toast']);
615 718 }
616 -
719 +
617 720 wp_send_json_success($data);
618 721 }
619 -
722 +
620 723 /**
621 724 * Send error response
622 725 */
623 726 private function sendError($message, $data = array()) {
@@ -626,12 +729,12 @@
626 729 'type' => 'error',
627 730 'message' => $message,
628 731 'options' => array('duration' => 6000)
629 732 );
630 -
733 +
631 734 wp_send_json_error($data);
632 735 }
633 -
736 +
634 737 /**
635 738 * Download invoice as PDF
636 739 */
637 740 public function downloadPdf() {
@@ -636,32 +739,32 @@
636 739 */
637 740 public function downloadPdf() {
638 741 // Verify nonce
639 742 $this->verifyNonce('easy_invoice_nonce');
640 -
743 +
641 744 // Check if user has required capability
642 - if (!current_user_can('edit_posts')) {
745 + if (!easy_invoice_user_can('ei_view_invoices')) {
643 746 $this->sendError(__('You do not have permission to download invoices', 'easy-invoice'));
644 747 }
645 -
748 +
646 749 // Get invoice ID
647 750 $invoice_id = isset($_REQUEST['invoice_id']) ? intval($_REQUEST['invoice_id']) : 0;
648 -
751 +
649 752 if (!$invoice_id) {
650 753 $this->sendError(__('Invalid invoice ID', 'easy-invoice'));
651 754 }
652 -
755 +
653 756 // Get invoice from repository
654 757 $repository = InvoiceServiceProvider::getInvoiceRepository();
655 758 $invoice = $repository->find($invoice_id);
656 -
759 +
657 760 if (!$invoice) {
658 761 $this->sendError(__('Invoice not found', 'easy-invoice'));
659 762 }
660 -
763 +
661 764 // Get invoice data for PDF generation
662 765 $invoice_data = \EasyInvoice\Includes\Helpers\PdfHelper::getInvoiceDataForPdf($invoice);
663 -
766 +
664 767 // Return success response with invoice data
665 768 $this->sendSuccess(array(
666 769 'message' => __('Invoice data retrieved successfully', 'easy-invoice'),
667 770 'invoice_data' => $invoice_data
@@ -666,38 +769,45 @@
666 769 'message' => __('Invoice data retrieved successfully', 'easy-invoice'),
667 770 'invoice_data' => $invoice_data
668 771 ));
669 772 }
670 -
773 +
671 774 /**
672 775 * Send invoice via email
673 776 */
674 777 public function sendInvoiceEmail() {
675 778 $this->verifyNonce('easy_invoice_nonce');
676 -
677 - if (!current_user_can('manage_options')) {
779 +
780 + if (!easy_invoice_user_can('ei_send_invoice')) {
678 781 $this->sendError(__('You do not have permission to perform this action', 'easy-invoice'));
679 782 }
680 -
783 +
681 784 // Get invoice ID from POST data
682 785 $invoice_id = isset($_POST['invoice_id']) ? intval($_POST['invoice_id']) : 0;
683 -
786 +
684 787 if (!$invoice_id) {
685 788 $this->sendError(__('Invalid invoice ID', 'easy-invoice'));
686 789 }
687 -
790 +
688 791 $repository = InvoiceServiceProvider::getInvoiceRepository();
689 792 $invoice = $repository->find($invoice_id);
690 -
793 +
691 794 if (!$invoice) {
692 795 $this->sendError(__('Invoice not found', 'easy-invoice'));
693 796 }
694 -
797 +
695 798 // Use EmailManager to send the email
696 799 $email_manager = \EasyInvoice\Services\EmailManager::getInstance();
697 800 $result = $email_manager->sendInvoiceEmail($invoice, 'new');
698 -
801 +
699 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 + }
700 810 $this->sendSuccess(array(
701 811 'message' => $result['message']
702 812 ));
703 813 } else {
@@ -703,9 +813,9 @@
703 813 } else {
704 814 $this->sendError($result['message']);
705 815 }
706 816 }
707 -
817 +
708 818 /**
709 819 * Download quote as PDF
710 820 */
711 821 public function downloadQuotePdf() {
@@ -710,29 +820,29 @@
710 820 */
711 821 public function downloadQuotePdf() {
712 822 // Verify nonce
713 823 $this->verifyNonce('easy_invoice_nonce');
714 -
824 +
715 825 // Check if user has required capability
716 - if (!current_user_can('edit_posts')) {
826 + if (!easy_invoice_user_can('ei_view_quotes')) {
717 827 $this->sendError(__('You do not have permission to download quotes', 'easy-invoice'));
718 828 }
719 -
829 +
720 830 // Get quote ID
721 831 $quote_id = isset($_POST['quote_id']) ? intval($_POST['quote_id']) : 0;
722 -
832 +
723 833 if (!$quote_id) {
724 834 $this->sendError(__('Invalid quote ID', 'easy-invoice'));
725 835 }
726 -
836 +
727 837 // Get quote from repository
728 838 $repository = \EasyInvoice\Providers\QuoteServiceProvider::getQuoteRepository();
729 839 $quote = $repository->find($quote_id);
730 -
840 +
731 841 if (!$quote) {
732 842 $this->sendError(__('Quote not found', 'easy-invoice'));
733 843 }
734 -
844 +
735 845 // For now, return success response with quote data
736 846 // PDF generation can be implemented later with actual PDF creation
737 847 $this->sendSuccess(array(
738 848 'message' => __('Quote data retrieved successfully', 'easy-invoice'),
@@ -739,45 +849,42 @@
739 849 'quote_data' => $quote->toArray(),
740 850 'download_url' => add_query_arg(array(
741 851 'action' => 'easy_invoice_generate_quote_pdf',
742 852 'quote_id' => $quote_id,
743 - 'nonce' => wp_create_nonce('generate_quote_pdf')
853 + // Bound to this quote — see the invoice equivalent above.
854 + 'nonce' => wp_create_nonce('generate_quote_pdf_' . $quote_id)
744 855 ), admin_url('admin-ajax.php'))
745 856 ));
746 857 }
747 -
858 +
748 859 /**
749 860 * Save quote
750 861 */
751 862 public function saveQuote() {
752 863 $this->verifyNonce('easy_invoice_nonce');
753 -
754 - if (!current_user_can('manage_options')) {
864 +
865 + if (!easy_invoice_user_can('ei_create_quote')) {
755 866 $this->sendError(__('You do not have permission to perform this action', 'easy-invoice'));
756 867 }
757 -
868 +
758 869 // Get the raw quote data from the form
759 870 $raw_quote_data = isset($_POST['quote_data']) ? $_POST['quote_data'] : $_POST;
760 -
761 - // Debug: Log the raw POST data
762 - error_log("Raw POST data for quote: " . print_r($_POST, true));
763 - error_log("Raw quote data: " . print_r($raw_quote_data, true));
764 -
871 +
765 872 // Remove non-quote fields
766 873 unset($raw_quote_data['action']);
767 874 unset($raw_quote_data['nonce']);
768 -
875 +
769 876 // Process the quote data
770 877 $quote_form_manager = new \EasyInvoice\Forms\Quote\QuoteFormManager();
771 878 $quote_data = $quote_form_manager->processFormData($raw_quote_data);
772 -
879 +
773 880 if (!empty($quote_data['errors'])) {
774 881 wp_send_json_error([
775 - 'message' => 'Validation failed',
882 + 'message' => implode(' ', array_map('strval', $quote_data['errors'])),
776 883 'errors' => $quote_data['errors']
777 884 ]);
778 885 }
779 -
886 +
780 887 // Handle items separately - process the natural form submission format
781 888 if (isset($raw_quote_data['items']) && is_array($raw_quote_data['items'])) {
782 889 // Form submits items as items[0][title], items[0][description], etc.
783 890 // Convert to array of item objects for processing
@@ -786,35 +893,28 @@
786 893 if (is_array($item_data)) {
787 894 $items_array[] = $item_data;
788 895 }
789 896 }
790 -
791 - // Debug: Log the raw items data
792 - error_log("Raw quote items data: " . print_r($raw_quote_data['items'], true));
793 - error_log("Processed items array: " . print_r($items_array, true));
794 -
897 +
795 898 // Process items using the dynamic field system
796 899 $quote_data['data']['items'] = $quote_form_manager->processItemsData($items_array);
797 -
798 - // Debug: Log the processed items data
799 - error_log("Final processed items: " . print_r($quote_data['data']['items'], true));
800 900 }
801 -
901 +
802 902 // Handle special fields that might not be in the form definition
803 903 if (isset($raw_quote_data['quote_id'])) {
804 904 $quote_data['data']['quote_id'] = intval($raw_quote_data['quote_id']);
805 905 }
806 -
906 +
807 907 if (isset($raw_quote_data['client_id'])) {
808 908 $quote_data['data']['client_id'] = intval($raw_quote_data['client_id']);
809 909 }
810 -
811 910
812 -
911 +
912 +
813 913 $quote_id = isset($quote_data['data']['quote_id']) ? intval($quote_data['data']['quote_id']) : 0;
814 -
914 +
815 915 $repository = \EasyInvoice\Providers\QuoteServiceProvider::getQuoteRepository();
816 -
916 +
817 917 if ($quote_id > 0) {
818 918 // Update existing quote - preserve existing quote number
819 919 unset($quote_data['data']['quote_number']);
820 920 unset($quote_data['data']['number']);
@@ -820,55 +920,62 @@
820 920 unset($quote_data['data']['number']);
821 921
822 922 // Get the existing quote first
823 923 $quote = $repository->find($quote_id);
824 -
924 +
825 925 if (!$quote) {
826 926 $this->sendError(__('Failed to find quote for update', 'easy-invoice'));
827 927 }
828 -
928 +
829 929 // Use FormProcessor to save form data to database BEFORE repository update
830 930 $form_processor = new \EasyInvoice\Forms\FormProcessor();
831 931 $all_fields = $quote_form_manager->getAllFields();
832 932 $form_processor->saveFormDataToDatabase($quote_data['data'], $all_fields, $quote);
833 -
933 +
834 934 // Now update the quote with the processed data, passing the existing quote object
835 935 $quote = $repository->update($quote_id, $quote_data['data'], $quote);
836 -
936 +
837 937 if (!$quote) {
838 938 $this->sendError(__('Failed to update quote', 'easy-invoice'));
839 939 }
840 -
940 +
841 941 $message = __('Quote updated successfully', 'easy-invoice');
842 942 } else {
843 943 // Create new quote - allow auto-generated quote number to be saved
844 944 // The quote number will be auto-generated by the form and included in the data
845 -
945 +
846 946 $quote = $repository->create($quote_data['data']);
847 -
947 +
848 948 if (!$quote) {
849 949 $this->sendError(__('Failed to create quote', 'easy-invoice'));
850 950 }
851 -
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 +
852 957 // Use FormProcessor to save form data to database
853 958 $form_processor = new \EasyInvoice\Forms\FormProcessor();
854 959 $all_fields = $quote_form_manager->getAllFields();
855 960 $form_processor->saveFormDataToDatabase($quote_data['data'], $all_fields, $quote);
856 -
961 +
857 962 $quote_id = $quote->getId();
858 963 $message = __('Quote created successfully', 'easy-invoice');
859 964 }
860 -
965 +
861 966 // Handle items
862 967 if (isset($quote_data['data']['items']) && is_array($quote_data['data']['items'])) {
863 - error_log("Setting quote items: " . print_r($quote_data['data']['items'], true));
864 968 $quote->setItems($quote_data['data']['items']);
865 969 // Save the quote to persist the items to database
866 - error_log("Calling quote->save() to persist items");
867 970 $quote->save();
868 - // Quote save completed
869 971 }
870 -
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 );
871 978 // Prepare response data
872 979 $response_data = array(
873 980 'quote_id' => $quote_id,
874 981 'quote' => $quote->toArray(),
@@ -877,9 +984,9 @@
877 984 'message' => $message,
878 985 'options' => array('duration' => 4000)
879 986 )
880 987 );
881 -
988 +
882 989 // Include client data if quote has a client
883 990 if ($quote->getClientId()) {
884 991 $client_repository = ClientServiceProvider::getClientRepository();
885 992 $client = $client_repository->find($quote->getClientId());
@@ -894,45 +1001,47 @@
894 1001 'website' => $client->getWebsite() ?: '',
895 1002 );
896 1003 }
897 1004 }
898 -
899 -
1005 +
1006 +
900 1007 $this->sendSuccess($response_data);
901 1008 }
902 -
1009 +
903 1010 /**
904 1011 * Toggle a template as favorite
905 1012 */
906 1013
907 -
908 1014
909 -
1015 +
1016 +
910 1017 /**
911 1018 * Check if an email already exists for any client
912 1019 */
913 1020 public function checkEmailExists() {
914 1021 $this->verifyNonce('easy_invoice_nonce');
915 -
916 - if (!current_user_can('manage_options')) {
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')) {
917 1026 $this->sendError(__('You do not have permission to perform this action', 'easy-invoice'));
918 1027 }
919 -
1028 +
920 1029 $email = isset($_POST['email']) ? sanitize_email($_POST['email']) : '';
921 -
1030 +
922 1031 if (empty($email)) {
923 1032 $this->sendSuccess(array('exists' => false));
924 1033 }
925 -
1034 +
926 1035 $repository = ClientServiceProvider::getClientRepository();
927 1036 $existing_clients = $repository->findByEmail($email);
928 -
1037 +
929 1038 $this->sendSuccess(array(
930 1039 'exists' => !empty($existing_clients),
931 1040 'count' => count($existing_clients)
932 1041 ));
933 1042 }
934 -
1043 +
935 1044 /**
936 1045 * Generate a secure password.
937 1046 */
938 1047 public function generatePassword() {
@@ -937,9 +1046,10 @@
937 1046 */
938 1047 public function generatePassword() {
939 1048 $this->verifyNonce('easy_invoice_nonce');
940 1049
941 - if (!current_user_can('manage_options')) {
1050 + // Used when creating a client (WP user); same gate as client management.
1051 + if (!easy_invoice_user_can('ei_manage_clients')) {
942 1052 $this->sendError(__('You do not have permission to perform this action', 'easy-invoice'));
943 1053 }
944 1054
945 1055 $password = wp_generate_password(16, true, true);
@@ -960,26 +1070,26 @@
960 1070 * @return array Sanitized items data
961 1071 */
962 1072 private function sanitizeItems(array $items): array {
963 1073 $sanitized_items = [];
964 -
1074 +
965 1075 // Get field configuration for dynamic processing
966 1076 $form_manager = new \EasyInvoice\Forms\Invoice\InvoiceFormManager();
967 1077 $field_config = $form_manager->getItemFields();
968 -
1078 +
969 1079 foreach ($items as $item) {
970 1080 if (!is_array($item)) {
971 1081 continue;
972 1082 }
973 -
1083 +
974 1084 $sanitized_item = [];
975 -
1085 +
976 1086 // Process each field dynamically based on configuration
977 1087 foreach ($field_config as $field) {
978 1088 $field_name = $field['name'] ?? '';
979 1089 $field_type = $field['type'] ?? 'text';
980 1090 $raw_value = $item[$field_name] ?? '';
981 -
1091 +
982 1092 // Apply field-specific sanitization
983 1093 switch ($field_type) {
984 1094 case 'text':
985 1095 $sanitized_item[$field_name] = sanitize_text_field($raw_value);
@@ -997,9 +1107,9 @@
997 1107 $sanitized_item[$field_name] = sanitize_text_field($raw_value);
998 1108 break;
999 1109 }
1000 1110 }
1001 -
1111 +
1002 1112 // Handle legacy field names for backward compatibility
1003 1113 if (isset($item['name']) && !isset($sanitized_item['title'])) {
1004 1114 $sanitized_item['title'] = sanitize_text_field($item['name']);
1005 1115 }
@@ -1005,15 +1115,15 @@
1005 1115 }
1006 1116 if (isset($item['title']) && !isset($sanitized_item['title'])) {
1007 1117 $sanitized_item['title'] = sanitize_text_field($item['title']);
1008 1118 }
1009 -
1119 +
1010 1120 // Only add items that have at least a title/name
1011 1121 if (!empty($sanitized_item['title'])) {
1012 1122 $sanitized_items[] = $sanitized_item;
1013 1123 }
1014 1124 }
1015 -
1125 +
1016 1126 return $sanitized_items;
1017 1127 }
1018 1128
1019 1129 /**
@@ -1019,39 +1129,43 @@
1019 1129 /**
1020 1130 * Add a new client (specifically for the client form in templates/clients-page.php)
1021 1131 */
1022 1132 public function addClient() {
1023 -
1133 +
1024 1134 $this->verifyNonce('easy_invoice_nonce');
1025 -
1026 - if (!current_user_can('manage_options')) {
1135 +
1136 + if (!easy_invoice_user_can('ei_manage_clients')) {
1027 1137 $this->sendError(__('You do not have permission to perform this action', 'easy-invoice'));
1028 1138 }
1029 -
1139 +
1030 1140 // Check if required fields are present
1031 1141 $required_fields = ['business_client_name', 'email', 'username'];
1032 1142 foreach ($required_fields as $field) {
1033 1143 if (!isset($_POST[$field]) || empty($_POST[$field])) {
1034 - $this->sendError(__('Missing required field: ' . $field, 'easy-invoice'));
1144 + /* translators: %s: form field name. */
1145 + $this->sendError(sprintf(__('Missing required field: %s', 'easy-invoice'), $field));
1035 1146 }
1036 1147 }
1037 -
1148 +
1038 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
1039 1153 $client_data = [
1040 - ClientFields::BUSINESS_CLIENT_NAME => sanitize_text_field($_POST['business_client_name']),
1041 - ClientFields::EMAIL => sanitize_email($_POST['email']),
1042 - ClientFields::USERNAME => sanitize_user($_POST['username']),
1043 - ClientFields::PASSWORD => $_POST['password'],
1044 - ClientFields::ADDRESS => sanitize_textarea_field($_POST['address']),
1045 - ClientFields::EXTRA_INFO => sanitize_textarea_field($_POST['extra_info']),
1046 - ClientFields::FIRST_NAME => sanitize_text_field($_POST['first_name']),
1047 - ClientFields::LAST_NAME => sanitize_text_field($_POST['last_name']),
1048 - ClientFields::WEBSITE => esc_url_raw($_POST['website']),
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'])) : '',
1049 1163 ClientFields::PHONE => isset($_POST['phone']) ? sanitize_text_field($_POST['phone']) : '',
1050 1164 ];
1051 -
1052 1165
1053 1166
1167 +
1054 1168 // Basic validation
1055 1169 if (empty($client_data[ClientFields::BUSINESS_CLIENT_NAME]) && (empty($client_data[ClientFields::FIRST_NAME]) || empty($client_data[ClientFields::LAST_NAME]))) {
1056 1170 $this->sendError(__('Please provide a client name or first/last name.', 'easy-invoice'));
1057 1171 }
@@ -1058,57 +1172,68 @@
1058 1172
1059 1173 if (empty($client_data[ClientFields::EMAIL])) {
1060 1174 $this->sendError(__('Email address is required', 'easy-invoice'));
1061 1175 }
1062 -
1176 +
1063 1177 $repository = ClientServiceProvider::getClientRepository();
1064 -
1178 +
1065 1179 // Create new client
1066 1180 $client = $repository->create($client_data);
1067 -
1181 +
1068 1182 if (!$client) {
1069 - $this->sendError(__('Failed to create client', 'easy-invoice'));
1183 + $reason = method_exists($repository, 'getLastError') ? $repository->getLastError() : '';
1184 + $this->sendError($reason !== '' ? $reason : __('Failed to create client', 'easy-invoice'));
1070 1185 }
1071 -
1186 +
1072 1187 $client_id = $client->getId();
1073 -
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 +
1074 1197 $response_data = array(
1075 1198 'message' => __('Client added successfully', 'easy-invoice'),
1076 1199 'client_id' => $client_id,
1200 + 'role' => $role,
1201 + 'role_label' => ucfirst($role),
1077 1202 'client' => $client->toArray(),
1078 1203 );
1079 -
1204 +
1080 1205 $this->sendSuccess($response_data);
1081 1206 }
1082 -
1207 +
1083 1208 /**
1084 1209 * Update client from the client edit form
1085 1210 */
1086 1211 public function updateClient() {
1087 1212 $this->verifyNonce('easy_invoice_nonce');
1088 -
1089 - if (!current_user_can('manage_options')) {
1213 +
1214 + if (!easy_invoice_user_can('ei_manage_clients')) {
1090 1215 $this->sendError(__('You do not have permission to perform this action', 'easy-invoice'));
1091 1216 }
1092 -
1217 +
1093 1218 $client_id = isset($_POST['client_id']) ? intval($_POST['client_id']) : 0;
1094 -
1219 +
1095 1220 if ($client_id <= 0) {
1096 1221 $this->sendError(__('Invalid client ID', 'easy-invoice'));
1097 1222 }
1098 -
1223 +
1099 1224 // Prepare client data
1100 1225 $client_data = [
1101 - ClientFields::BUSINESS_CLIENT_NAME => sanitize_text_field($_POST['business_client_name']),
1102 - ClientFields::EMAIL => sanitize_email($_POST['email']),
1103 - ClientFields::USERNAME => sanitize_user($_POST['username']),
1104 - ClientFields::PASSWORD => $_POST['password'], // Keep password as is, don't sanitize
1105 - ClientFields::ADDRESS => sanitize_textarea_field($_POST['address']),
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'] ?? '')),
1106 1231 ClientFields::PHONE => isset($_POST['phone']) ? sanitize_text_field($_POST['phone']) : '',
1107 - ClientFields::EXTRA_INFO => sanitize_textarea_field($_POST['extra_info']),
1108 - ClientFields::FIRST_NAME => sanitize_text_field($_POST['first_name']),
1109 - ClientFields::LAST_NAME => sanitize_text_field($_POST['last_name']),
1110 - ClientFields::WEBSITE => esc_url_raw($_POST['website'])
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'] ?? ''))
1111 1236 ];
1112 1237
1113 1238 // Remove empty values except password (password can be empty for updates)
1114 1239 $client_data = array_filter($client_data, function($value, $key) {
@@ -1120,18 +1245,18 @@
1120 1245
1121 1246 if (empty($client_data)) {
1122 1247 $this->sendError(__('No data provided to update.', 'easy-invoice'));
1123 1248 }
1124 -
1249 +
1125 1250 $repository = ClientServiceProvider::getClientRepository();
1126 -
1251 +
1127 1252 // Update existing client
1128 1253 $client = $repository->update($client_id, $client_data);
1129 -
1254 +
1130 1255 if (!$client) {
1131 1256 $this->sendError(__('Failed to update client', 'easy-invoice'));
1132 1257 }
1133 -
1258 +
1134 1259 $this->sendSuccess(array(
1135 1260 'message' => __('Client updated successfully', 'easy-invoice'),
1136 1261 'client_id' => $client_id,
1137 1262 'client' => $client->toArray(),
@@ -1142,23 +1267,24 @@
1142 1267 * Update invoices data with missing client information and totals
1143 1268 */
1144 1269 public function updateInvoicesData() {
1145 1270 $this->verifyNonce('easy_invoice_admin_nonce');
1146 -
1271 +
1272 + // Bulk migration / repair of invoice records — admin-only.
1147 1273 if (!current_user_can('manage_options')) {
1148 1274 $this->sendError(__('You do not have permission to perform this action', 'easy-invoice'));
1149 1275 }
1150 -
1276 +
1151 1277 $repository = InvoiceServiceProvider::getInvoiceRepository();
1152 1278 $client_repository = ClientServiceProvider::getClientRepository();
1153 -
1279 +
1154 1280 // Get all invoices
1155 1281 $invoices = $repository->all();
1156 1282 $updated_count = 0;
1157 -
1283 +
1158 1284 foreach ($invoices as $invoice) {
1159 1285 $updated = false;
1160 -
1286 +
1161 1287 // Check if client data is missing
1162 1288 $client_id = $invoice->getClientId();
1163 1289 if ($client_id > 0) {
1164 1290 $client = $client_repository->find($client_id);
@@ -1169,9 +1295,9 @@
1169 1295 $customer_name = $client->getBusinessClientName() ?: ($client->getFirstName() . ' ' . $client->getLastName());
1170 1296 $invoice->setCustomerName($customer_name);
1171 1297 $updated = true;
1172 1298 }
1173 -
1299 +
1174 1300 // Update customer email if missing
1175 1301 $customer_email = $invoice->getCustomerEmail();
1176 1302 if (empty($customer_email)) {
1177 1303 $customer_email = $client->getEmail();
@@ -1177,9 +1303,9 @@
1177 1303 $customer_email = $client->getEmail();
1178 1304 $invoice->setCustomerEmail($customer_email);
1179 1305 $updated = true;
1180 1306 }
1181 -
1307 +
1182 1308 // Update customer address if missing
1183 1309 $customer_address = $invoice->getCustomerAddress();
1184 1310 if (empty($customer_address)) {
1185 1311 $customer_address = $client->getAddress();
@@ -1187,9 +1313,9 @@
1187 1313 $updated = true;
1188 1314 }
1189 1315 }
1190 1316 }
1191 -
1317 +
1192 1318 // Check if total is missing or zero
1193 1319 $total = $invoice->getTotal();
1194 1320 if (empty($total) || $total == 0) {
1195 1321 // Recalculate total from items
@@ -1202,27 +1328,28 @@
1202 1328 } elseif (isset($item['amount'])) {
1203 1329 $subtotal += $item['amount'];
1204 1330 }
1205 1331 }
1206 -
1332 +
1207 1333 // Calculate discount and tax
1208 1334 $discount = $invoice->getDiscountAmount();
1209 1335 $tax = $invoice->getTaxAmount();
1210 -
1336 +
1211 1337 $total = $subtotal - $discount + $tax;
1212 -
1338 +
1213 1339 // Save the calculated total
1214 1340 $invoice->setMeta('_easy_invoice_total', $total);
1215 1341 $updated = true;
1216 1342 }
1217 1343 }
1218 -
1344 +
1219 1345 if ($updated) {
1220 1346 $updated_count++;
1221 1347 }
1222 1348 }
1223 -
1349 +
1224 1350 $this->sendSuccess(array(
1351 + /* translators: %d: number updated. */
1225 1352 'message' => sprintf(__('Updated %d invoices with missing data', 'easy-invoice'), $updated_count),
1226 1353 'updated_count' => $updated_count
1227 1354 ));
1228 1355 }
@@ -1232,34 +1359,43 @@
1232 1359 */
1233 1360 public function downloadInvoicePdf() {
1234 1361 // Verify nonce
1235 1362 $this->verifyNonce('easy_invoice_nonce');
1236 -
1363 +
1237 1364 // Get invoice ID
1238 1365 $invoice_id = isset($_POST['invoice_id']) ? intval($_POST['invoice_id']) : 0;
1239 -
1366 +
1240 1367 if (!$invoice_id) {
1241 1368 $this->sendError(__('Invalid invoice ID', 'easy-invoice'));
1242 1369 }
1243 -
1244 - // Get invoice from repository (only published invoices for public access)
1370 +
1245 1371 $repository = InvoiceServiceProvider::getInvoiceRepository();
1246 -
1247 - // For admins, allow access to any invoice status
1248 - if (current_user_can('manage_options')) {
1249 - $invoice = $repository->find($invoice_id);
1250 - } else {
1251 - // For non-admins, only allow access to published invoices
1252 - $invoice = $repository->findPublished($invoice_id);
1253 - }
1254 -
1372 + $invoice = $repository->find($invoice_id);
1373 +
1255 1374 if (!$invoice) {
1256 1375 $this->sendError(__('Invoice not found', 'easy-invoice'));
1257 1376 }
1258 -
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 +
1259 1395 // Get invoice data for PDF generation
1260 1396 $invoice_data = \EasyInvoice\Includes\Helpers\PdfHelper::getInvoiceDataForPdf($invoice);
1261 -
1397 +
1262 1398 // For now, return success response with invoice data
1263 1399 // PDF generation can be implemented later with actual PDF creation
1264 1400 $this->sendSuccess(array(
1265 1401 'message' => __('Invoice data retrieved successfully', 'easy-invoice'),
@@ -1266,45 +1402,50 @@
1266 1402 'invoice_data' => $invoice_data,
1267 1403 'download_url' => add_query_arg(array(
1268 1404 'action' => 'easy_invoice_generate_pdf',
1269 1405 'invoice_id' => $invoice_id,
1270 - 'nonce' => wp_create_nonce('generate_pdf')
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)
1271 1410 ), admin_url('admin-ajax.php'))
1272 1411 ));
1273 1412 }
1274 -
1413 +
1275 1414 /**
1276 1415 * Send invoice via email (public access)
1277 1416 */
1278 1417 public function sendInvoiceEmailPublic() {
1279 1418 $this->verifyNonce('easy_invoice_send_invoice_email');
1280 -
1419 +
1281 1420 // Get invoice ID
1282 1421 $invoice_id = isset($_POST['invoice_id']) ? intval($_POST['invoice_id']) : 0;
1283 -
1422 +
1284 1423 if (!$invoice_id) {
1285 1424 $this->sendError(__('Invalid invoice ID', 'easy-invoice'));
1286 1425 }
1287 -
1426 +
1288 1427 // Get invoice from repository (only published invoices for public access)
1289 1428 $repository = InvoiceServiceProvider::getInvoiceRepository();
1290 -
1291 - // For admins, allow access to any invoice status
1292 - if (current_user_can('manage_options')) {
1293 - $invoice = $repository->find($invoice_id);
1294 - } else {
1295 - // For non-admins, only allow access to published invoices
1296 - $invoice = $repository->findPublished($invoice_id);
1297 - }
1298 -
1429 + $invoice = $repository->find($invoice_id);
1430 +
1299 1431 if (!$invoice) {
1300 1432 $this->sendError(__('Invoice not found', 'easy-invoice'));
1301 1433 }
1302 -
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 +
1303 1444 // Use EmailManager to send the email
1304 1445 $email_manager = \EasyInvoice\Services\EmailManager::getInstance();
1305 1446 $result = $email_manager->sendInvoiceEmail($invoice, 'new');
1306 -
1447 +
1307 1448 if ($result['success']) {
1308 1449 $this->sendSuccess(array(
1309 1450 'message' => $result['message']
1310 1451 ));
@@ -1311,26 +1452,123 @@
1311 1452 } else {
1312 1453 $this->sendError($result['message']);
1313 1454 }
1314 1455 }
1315 -
1456 +
1316 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 + /**
1317 1498 * Generate invoice PDF
1318 1499 */
1319 1500 public function generateInvoicePdf() {
1320 - // Verify nonce
1321 - $this->verifyNonce('generate_pdf');
1322 -
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 +
1323 1512 // Get invoice ID
1324 1513 $invoice_id = isset($_REQUEST['invoice_id']) ? intval($_REQUEST['invoice_id']) : 0;
1325 -
1514 +
1326 1515 if (!$invoice_id) {
1327 1516 $this->sendError(__('Invalid invoice ID', 'easy-invoice'));
1328 1517 }
1329 -
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 +
1330 1568 // Get invoice from repository
1331 1569 $repository = InvoiceServiceProvider::getInvoiceRepository();
1332 -
1570 +
1333 1571 // For admins, allow access to any invoice status
1334 1572 if (current_user_can('manage_options')) {
1335 1573 $invoice = $repository->find($invoice_id);
1336 1574 } else {
@@ -1336,77 +1574,190 @@
1336 1574 } else {
1337 1575 // For non-admins, only allow access to published invoices
1338 1576 $invoice = $repository->findPublished($invoice_id);
1339 1577 }
1340 -
1578 +
1341 1579 if (!$invoice) {
1342 1580 $this->sendError(__('Invoice not found', 'easy-invoice'));
1343 1581 }
1344 -
1345 - // Redirect to the invoice single page with PDF generation
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).
1346 1587 $invoice_url = get_permalink($invoice_id);
1347 - if ($invoice_url) {
1348 - wp_redirect(add_query_arg('auto_download_pdf', '1', $invoice_url));
1349 - exit;
1350 - } else {
1588 + if (!$invoice_url) {
1351 1589 $this->sendError(__('Could not generate invoice URL', 'easy-invoice'));
1352 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);
1353 1599 }
1354 -
1600 +
1355 1601 /**
1356 1602 * Generate quote PDF
1357 1603 */
1358 1604 public function generateQuotePdf() {
1359 - // Verify nonce
1360 - $this->verifyNonce('generate_quote_pdf');
1361 -
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 +
1362 1610 // Get quote ID
1363 1611 $quote_id = isset($_REQUEST['quote_id']) ? intval($_REQUEST['quote_id']) : 0;
1364 -
1612 +
1365 1613 if (!$quote_id) {
1366 1614 $this->sendError(__('Invalid quote ID', 'easy-invoice'));
1367 1615 }
1368 -
1369 - // Get quote from repository
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).
1370 1655 $repository = \EasyInvoice\Providers\QuoteServiceProvider::getQuoteRepository();
1371 - $quote = $repository->find($quote_id);
1372 -
1656 + if (current_user_can('manage_options')) {
1657 + $quote = $repository->find($quote_id);
1658 + } else {
1659 + $quote = $repository->findPublished($quote_id);
1660 + }
1661 +
1373 1662 if (!$quote) {
1374 1663 $this->sendError(__('Quote not found', 'easy-invoice'));
1375 1664 }
1376 -
1377 - // Redirect to the quote single page with PDF generation
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.
1378 1670 $quote_url = get_permalink($quote_id);
1379 - if ($quote_url) {
1380 - wp_redirect(add_query_arg('auto_download_pdf', '1', $quote_url));
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);
1381 1707 exit;
1382 - } else {
1383 - $this->sendError(__('Could not generate quote URL', 'easy-invoice'));
1384 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;
1385 1720 }
1386 -
1721 +
1387 1722 /**
1388 1723 * Search clients for the dropdown
1389 1724 */
1390 1725 public function searchClients() {
1391 1726 $this->verifyNonce('easy_invoice_nonce');
1392 -
1393 - if (!current_user_can('manage_options')) {
1727 +
1728 + if (!easy_invoice_user_can('ei_view_clients')) {
1394 1729 $this->sendError(__('You do not have permission to perform this action', 'easy-invoice'));
1395 1730 }
1396 -
1731 +
1397 1732 $query = isset($_POST['query']) ? sanitize_text_field($_POST['query']) : '';
1398 -
1733 +
1399 1734 // Get client repository
1400 1735 $client_repository = \EasyInvoice\Providers\ClientServiceProvider::getClientRepository();
1401 -
1736 +
1402 1737 // Search clients
1403 1738 $clients = $client_repository->search($query);
1404 -
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.
1405 1756 if (empty($clients)) {
1406 - $this->sendSuccess(array());
1757 + wp_send_json_success(array());
1407 1758 }
1408 -
1759 +
1409 1760 // Format clients for dropdown
1410 1761 $formatted_clients = array();
1411 1762 foreach ($clients as $client) {
1412 1763 // Get the WordPress user data directly
@@ -1413,23 +1764,23 @@
1413 1764 $user = get_user_by('id', $client->getId());
1414 1765 if (!$user) {
1415 1766 continue;
1416 1767 }
1417 -
1768 +
1418 1769 // Use Client model properties first, fallback to WordPress user fields
1419 1770 $business_name = $client->business_client_name ?: '';
1420 1771 $first_name = $client->first_name ?: $user->first_name ?: '';
1421 1772 $last_name = $client->last_name ?: $user->last_name ?: '';
1422 1773 $email = $client->email ?: $user->user_email ?: '';
1423 -
1424 1774
1425 -
1775 +
1776 +
1426 1777 // Create display name
1427 1778 $client_name = $business_name ?: ($first_name . ' ' . $last_name);
1428 1779 if (empty(trim($client_name))) {
1429 1780 $client_name = $user->display_name ?: 'User ' . $client->getId();
1430 1781 }
1431 -
1782 +
1432 1783 // Include all clients, even those with empty emails
1433 1784 $formatted_clients[] = array(
1434 1785 'id' => $client->getId(),
1435 1786 'name' => $client_name,
@@ -1440,8 +1791,119 @@
1440 1791 'website' => $client->website ?: '',
1441 1792 'display_name' => $client_name . ' (' . $email . ')'
1442 1793 );
1443 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'] : '';
1444 1841
1445 - $this->sendSuccess($formatted_clients);
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 + }
1446 1863 }
1447 -}
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 +}