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/Services/EmailManager.php +530 -335 2.1.102.4.0 View file →
@@ -79,17 +79,14 @@
79 79 /**
80 80 * Initialize hooks
81 81 */
82 82 private function initHooks(): void {
83 - // Register AJAX handlers
84 - add_action('wp_ajax_easy_invoice_send_invoice_email', [$this, 'handleSendInvoiceEmail']);
85 - add_action('wp_ajax_nopriv_easy_invoice_send_invoice_email', [$this, 'handleSendInvoiceEmail']);
86 - add_action('wp_ajax_easy_invoice_send_quote_email', [$this, 'handleSendQuoteEmail']);
87 - add_action('wp_ajax_nopriv_easy_invoice_send_quote_email', [$this, 'handleSendQuoteEmail']);
88 -
83 + // Invoice/quote send-email AJAX is handled exclusively by EasyInvoice\Admin\EasyInvoiceAjax
84 + // (published-document checks, single handler) to avoid duplicate nopriv callbacks.
85 +
89 86 // Add email settings to admin
90 87 add_action('admin_init', [$this, 'registerEmailSettings']);
91 - add_filter('easy_invoice_settings_sections', [$this, 'addEmailSettingsSection']);
88 + add_action('admin_init', [__CLASS__, 'refreshStockTemplates']);
92 89
93 90 // Refresh settings when they're updated
94 91 add_action('update_option_easy_invoice_email_from_name', [$this, 'refreshSettings']);
95 92 add_action('update_option_easy_invoice_email_from_address', [$this, 'refreshSettings']);
@@ -110,8 +107,16 @@
110 107 add_action('easy_invoice_email_failed', [$this, 'logEmailFailed'], 10, 3);
111 108
112 109 // Listen for payment completion to send admin notifications
113 110 add_action('easy_invoice_payment_completed', [$this, 'handlePaymentCompleted'], 10, 3);
111 + // An instalment gets a receipt as well; the invoice just is not settled yet.
112 + add_action('easy_invoice_payment_received', [$this, 'handlePaymentCompleted'], 10, 3);
113 +
114 + // A client has submitted a manual payment (bank transfer, cheque,
115 + // cash, with or without proof) that now waits for verification. The
116 + // only listener used to live in an admin class nothing instantiates,
117 + // so the admin was never told.
118 + add_action('easy_invoice_manual_payment_submitted', [$this, 'handleManualPaymentSubmitted'], 10, 2);
114 119 }
115 120
116 121 /**
117 122 * Load email settings
@@ -201,8 +206,21 @@
201 206 return ['success' => false, 'message' => __('Email template not found', 'easy-invoice')];
202 207 }
203 208
204 209 $template = $this->templates[$template_key];
210 +
211 + /**
212 + * Filter an email template before subject and body are built.
213 + *
214 + * Runs for invoice, quote and payment emails, so a listener can
215 + * switch locale for the client or swap the template wholesale.
216 + * `easy_invoice_email_finished` fires once the send is over.
217 + *
218 + * @param array $template subject, body, enabled.
219 + * @param string $template_key invoice_new, quote_reminder, invoice_paid…
220 + * @param object $document Invoice or Quote model.
221 + */
222 + $template = (array) apply_filters('easy_invoice_email_template_data', $template, $template_key, $invoice);
205 223
206 224 // Check if email is enabled
207 225 if (!$template['enabled']) {
208 226 return ['success' => false, 'message' => __('Invoice available email is disabled', 'easy-invoice')];
@@ -211,18 +229,71 @@
211 229 // Prepare email data
212 230 $email_data = $this->prepareInvoiceEmailData($invoice, $template, $additional_data);
213 231
214 232 // Send email
233 + // Attach the invoice as a PDF, when the site has asked for it.
234 + //
235 + // This is the capability the browser-based renderer could never provide:
236 + // wp_mail() needs a file on disk, and until PdfRenderer existed the server
237 + // never held the document. Off by default so an upgrade does not silently
238 + // change what customers receive.
239 + $attachments = [];
240 + $attached_path = '';
241 + if ($this->shouldAttachInvoicePdf()) {
242 + $rendered = \EasyInvoice\Services\PdfRenderer::renderToFile($invoice, 'invoice');
243 + if (is_wp_error($rendered)) {
244 + // A failed attachment must never stop the invoice being sent.
245 + error_log('Easy Invoice: could not attach invoice PDF — ' . $rendered->get_error_message());
246 + } else {
247 + $attached_path = $rendered;
248 + $attachments[] = $rendered;
249 + }
250 + }
251 +
252 + /**
253 + * Filter the files sent with an invoice email.
254 + *
255 + * @param array $attachments Paths.
256 + * @param object $invoice Invoice model.
257 + * @param string $type 'invoice'.
258 + */
259 + $attachments = (array) apply_filters( 'easy_invoice_email_attachments', $attachments, $invoice, 'invoice' );
260 + $email_data['message'] = $this->attachmentWording($email_data['message'], !empty($attachments));
261 +
215 262 $sent = $this->sendEmail(
216 263 $email_data['to'],
217 264 $email_data['subject'],
218 265 $email_data['message'],
219 - $email_data['headers']
266 + $email_data['headers'],
267 + $attachments
220 268 );
269 +
270 + // The rendered PDF lives in the system temp directory; remove it once
271 + // wp_mail() has handed it to the transport.
272 + if ($attached_path !== '' && file_exists($attached_path)) {
273 + wp_delete_file($attached_path);
274 + }
221 275
276 + /**
277 + * Fires once an email send has finished, whether or not it went out.
278 + *
279 + * @param object $document Invoice or Quote model.
280 + * @param string $template_key Template key.
281 + * @param bool $sent Whether wp_mail() accepted it.
282 + */
283 + do_action('easy_invoice_email_finished', $invoice, $template_key, (bool) $sent);
284 +
222 285 if ($sent) {
223 286 // Log success
224 287 do_action('easy_invoice_email_sent', $invoice, $client_email, $template_type);
288 +
289 + // Emailing a draft issues it: from here on it is a document
290 + // the client holds, so it reads Available, is chased by
291 + // reminders and is corrected by credit note, not by editing.
292 + if ('new' === $template_type && 'draft' === strtolower((string) $invoice->getStatus())) {
293 + $invoice->setStatus('available');
294 + $invoice->save();
295 + }
225 296
226 297 return [
227 298 'success' => true,
228 299 'message' => __('Email sent successfully', 'easy-invoice'),
@@ -268,8 +339,10 @@
268 339 return ['success' => false, 'message' => __('Email template not found', 'easy-invoice')];
269 340 }
270 341
271 342 $template = $this->templates[$template_key];
343 + /** This filter is documented above in sendInvoiceEmail(). */
344 + $template = (array) apply_filters('easy_invoice_email_template_data', $template, $template_key, $quote);
272 345
273 346 // Check if email is enabled
274 347 if (!$template['enabled']) {
275 348 return ['success' => false, 'message' => __('Quote available email is disabled', 'easy-invoice')];
@@ -276,8 +349,23 @@
276 349 }
277 350
278 351 // Prepare email data
279 352 $email_data = $this->prepareQuoteEmailData($quote, $template, $additional_data);
353 +
354 + // Same setting as invoices: a PDF copy goes with the quote when asked for.
355 + $attachments = [];
356 + $attached_path = '';
357 + if ($this->shouldAttachInvoicePdf()) {
358 + $rendered = \EasyInvoice\Services\PdfRenderer::renderToFile($quote, 'quote');
359 + if (is_wp_error($rendered)) {
360 + error_log('Easy Invoice: could not attach quote PDF — ' . $rendered->get_error_message());
361 + } else {
362 + $attached_path = $rendered;
363 + $attachments[] = $rendered;
364 + }
365 + }
366 + $attachments = (array) apply_filters( 'easy_invoice_email_attachments', $attachments, $quote, 'quote' );
367 + $email_data['message'] = $this->attachmentWording($email_data['message'], !empty($attachments));
280 368
281 369 // Send email
282 370 $sent = $this->sendEmail(
283 371 $email_data['to'],
@@ -282,14 +370,33 @@
282 370 $sent = $this->sendEmail(
283 371 $email_data['to'],
284 372 $email_data['subject'],
285 373 $email_data['message'],
286 - $email_data['headers']
374 + $email_data['headers'],
375 + $attachments
287 376 );
377 + if ($attached_path !== '' && file_exists($attached_path)) {
378 + wp_delete_file($attached_path);
379 + }
288 380
381 + /**
382 + * Fires once an email send has finished, whether or not it went out.
383 + *
384 + * @param object $document Invoice or Quote model.
385 + * @param string $template_key Template key.
386 + * @param bool $sent Whether wp_mail() accepted it.
387 + */
388 + do_action('easy_invoice_email_finished', $quote, $template_key, (bool) $sent);
389 +
289 390 if ($sent) {
290 391 // Log success
291 392 do_action('easy_invoice_quote_email_sent', $quote, $client_email, $template_type);
393 +
394 + // A quote that has been emailed is "sent".
395 + if ('new' === $template_type && in_array(strtolower((string) $quote->getStatus()), ['draft', 'available'], true)) {
396 + $quote->setStatus('sent');
397 + $quote->save();
398 + }
292 399
293 400 return [
294 401 'success' => true,
295 402 'message' => __('Quote email sent successfully', 'easy-invoice'),
@@ -316,10 +423,12 @@
316 423 * @param array $additional_data Additional data
317 424 * @return array Email data
318 425 */
319 426 private function prepareInvoiceEmailData(Invoice $invoice, array $template, array $additional_data = []): array {
320 - // Get replacements
321 - $replacements = $this->getInvoiceReplacements($invoice, $additional_data);
427 + // Get replacements — the receipt needs the payment placeholders too.
428 + $replacements = !empty($additional_data['payment_receipt'])
429 + ? $this->getPaymentReplacements($invoice, $additional_data)
430 + : $this->getInvoiceReplacements($invoice, $additional_data);
322 431
323 432 // Process template
324 433 $subject = $this->processTemplate($template['subject'], $replacements);
325 434 $message = $this->processTemplate($template['body'], $replacements);
@@ -330,9 +439,9 @@
330 439 $message = $this->wrapInHtmlTemplate($message);
331 440 }
332 441
333 442 // Prepare headers
334 - $headers = $this->prepareEmailHeaders();
443 + $headers = $this->prepareEmailHeaders('invoice', $invoice);
335 444
336 445 // Add BCC to admin if enabled
337 446 if ($this->settings['bcc_admin'] === 'yes' && !empty($this->settings['admin_email'])) {
338 447 $headers[] = 'Bcc: ' . $this->settings['admin_email'];
@@ -367,9 +476,9 @@
367 476 $message = $this->wrapInHtmlTemplate($message);
368 477 }
369 478
370 479 // Prepare headers
371 - $headers = $this->prepareEmailHeaders();
480 + $headers = $this->prepareEmailHeaders('quote', $quote);
372 481
373 482 // Add BCC to admin if enabled
374 483 if ($this->settings['bcc_admin'] === 'yes' && !empty($this->settings['admin_email'])) {
375 484 $headers[] = 'Bcc: ' . $this->settings['admin_email'];
@@ -404,9 +513,9 @@
404 513 $message = $this->wrapInHtmlTemplate($message);
405 514 }
406 515
407 516 // Prepare headers
408 - $headers = $this->prepareEmailHeaders();
517 + $headers = $this->prepareEmailHeaders('receipt', $invoice);
409 518
410 519 // Add BCC to admin if enabled
411 520 if ($this->settings['bcc_admin'] === 'yes' && !empty($this->settings['admin_email'])) {
412 521 $headers[] = 'Bcc: ' . $this->settings['admin_email'];
@@ -432,15 +541,28 @@
432 541
433 542 // Secure link support
434 543 $invoice_url = get_permalink($invoice->getId());
435 544 $secure_links_enabled = get_option('easy_invoice_pro_enable_secure_links', 'no') === 'yes';
436 - if ($secure_links_enabled && class_exists('\EasyInvoicePro\Controllers\PermalinkController')) {
437 - $secure_url = \EasyInvoicePro\Controllers\PermalinkController::getInvoiceSecureLinkUrl($invoice->getId());
545 + if ($secure_links_enabled && class_exists('\EasyInvoicePro\Addons\SecureLinks\Controllers\PermalinkController')) {
546 + $secure_url = \EasyInvoicePro\Addons\SecureLinks\Controllers\PermalinkController::getInvoiceSecureLinkUrl($invoice->getId());
438 547 if ($secure_url) {
439 548 $invoice_url = $secure_url;
440 549 }
441 550 }
442 -
551 +
552 + // SECURITY: attach a per-invoice access token to the outbound URL
553 + // so the legitimate email recipient can submit manual payments
554 + // without needing to log in. The token is verified server-side in
555 + // PaymentController::submitManualPayment via
556 + // InvoiceController::canSubmitPaymentForInvoice. Empty-token
557 + // guard so a CSPRNG failure doesn't produce malformed `?ik=` URLs.
558 + $invoice_access_token = \EasyInvoice\Controllers\InvoiceController::invoiceAccessToken((int) $invoice->getId());
559 + if ($invoice_access_token !== '' && $invoice_url) {
560 + $invoice_url = add_query_arg('ik', $invoice_access_token, $invoice_url);
561 + // The recipient now holds a keyed link; the bare one may close (TemplateLoader::isLegacyOpenDocument).
562 + \EasyInvoice\TemplateLoader::markKeyedLinkSent((int) $invoice->getId());
563 + }
564 +
443 565 // Get client data for additional fields
444 566 $client = null;
445 567 if ($invoice->getClientId()) {
446 568 $client_repository = new \EasyInvoice\Repositories\ClientRepository();
@@ -454,26 +576,46 @@
454 576 '{{client_email}}' => $invoice->getCustomerEmail(),
455 577 '{{client_address}}' => $invoice->getCustomerAddress(),
456 578 '{{client_first_name}}' => $client ? $client->getFirstName() : '',
457 579 '{{client_last_name}}' => $client ? $client->getLastName() : '',
458 - '{{company_name}}' => get_bloginfo('name'),
580 + '{{company_name}}' => get_option('easy_invoice_company_name') ?: get_bloginfo('name'),
459 581 '{{company_email}}' => $this->settings['from_email'],
460 582 '{{company_phone}}' => get_option('easy_invoice_company_phone', ''),
461 583 '{{company_address}}' => get_option('easy_invoice_company_address', ''),
462 584 '{{company_website}}' => get_option('easy_invoice_company_website', ''),
463 585 '{{total_amount}}' => (new \EasyInvoice\Helpers\InvoiceFormatter($invoice))->format($invoice->getTotal()),
586 + '{{amount_due}}' => (new \EasyInvoice\Helpers\InvoiceFormatter($invoice))->format(\EasyInvoice\Services\InvoiceBalance::due($invoice)),
464 587 '{{subtotal}}' => (new \EasyInvoice\Helpers\InvoiceFormatter($invoice))->format($invoice->getSubtotal()),
465 588 '{{tax_amount}}' => (new \EasyInvoice\Helpers\InvoiceFormatter($invoice))->format($invoice->getTaxAmount()),
466 589 '{{discount_amount}}' => (new \EasyInvoice\Helpers\InvoiceFormatter($invoice))->format($invoice->getDiscountAmount()),
467 - '{{due_date}}' => date('F j, Y', strtotime($invoice->getDueDate())),
468 - '{{issue_date}}' => date('F j, Y', strtotime($invoice->getIssueDate())),
590 + '{{due_date}}' => gmdate('F j, Y', strtotime($invoice->getDueDate())),
591 + '{{issue_date}}' => gmdate('F j, Y', strtotime($invoice->getIssueDate())),
469 592 '{{invoice_url}}' => $invoice_url, // Use correct (possibly secure) link
470 593 '{{payment_url}}' => add_query_arg('payment', '1', get_permalink($invoice->getId())),
471 594 '{{site_url}}' => get_site_url(),
472 595 '{{admin_url}}' => admin_url(),
473 596 '{{payment_terms}}' => get_option('easy_invoice_payment_terms', __('Due on receipt', 'easy-invoice')),
474 - ], $additional_data);
597 + ], self::placeholderKeysOnly($additional_data));
475 598 }
599 +
600 + /**
601 + * Keep only entries shaped like placeholders. Callers pass raw payment
602 + * data ('amount', 'date', 'payment_method') alongside; merged as-is those
603 + * became replacements of the bare words, turning "{{payment_amount}}"
604 + * into "{{payment_40}}" and every "date" in the text into a date.
605 + *
606 + * @param array $data Mixed data.
607 + * @return array<string,string>
608 + */
609 + private static function placeholderKeysOnly(array $data): array {
610 + $out = [];
611 + foreach ($data as $key => $value) {
612 + if (is_string($key) && 0 === strpos($key, '{{') && is_scalar($value)) {
613 + $out[$key] = (string) $value;
614 + }
615 + }
616 + return $out;
617 + }
476 618
477 619 /**
478 620 * Get quote replacements
479 621 *
@@ -485,14 +627,28 @@
485 627 $currency_symbol = get_option('easy_invoice_currency_symbol', '$');
486 628
487 629 $quote_url = get_permalink($quote->getId());
488 630 $secure_links_enabled = get_option('easy_invoice_pro_enable_secure_links', 'no') === 'yes';
489 - if ($secure_links_enabled && class_exists('\EasyInvoicePro\Controllers\PermalinkController')) {
490 - $secure_url = \EasyInvoicePro\Controllers\PermalinkController::getQuoteSecureLinkUrl($quote->getId());
631 + if ($secure_links_enabled && class_exists('\EasyInvoicePro\Addons\SecureLinks\Controllers\PermalinkController')) {
632 + $secure_url = \EasyInvoicePro\Addons\SecureLinks\Controllers\PermalinkController::getQuoteSecureLinkUrl($quote->getId());
491 633 if ($secure_url) {
492 634 $quote_url = $secure_url;
493 635 }
494 636 }
637 +
638 + // SECURITY (CVE-2026-9021): attach the per-quote access token so
639 + // the emailed recipient lands on a page that renders the
640 + // Accept/Decline UI and can submit either action without
641 + // authenticating. Without the token the public single-quote page
642 + // is read-only (no buttons, no nonce in DOM). Lazily generates
643 + // the token on first send. The query parameter name is
644 + // intentionally short ('qk') and opaque — leaking it via referer
645 + // headers is no worse than leaking the secure-link signature.
646 + $quote_access_token = \EasyInvoice\Controllers\QuoteController::quoteAccessToken((int) $quote->getId());
647 + if ($quote_access_token !== '' && $quote_url) {
648 + $quote_url = add_query_arg('qk', $quote_access_token, $quote_url);
649 + \EasyInvoice\TemplateLoader::markKeyedLinkSent((int) $quote->getId());
650 + }
495 651
496 652 // Get client data for additional fields
497 653 $client = null;
498 654 if ($quote->getClientId()) {
@@ -507,9 +663,9 @@
507 663 '{{client_email}}' => $quote->getCustomerEmail(),
508 664 '{{client_address}}' => $quote->getCustomerAddress(),
509 665 '{{client_first_name}}' => $client ? $client->getFirstName() : '',
510 666 '{{client_last_name}}' => $client ? $client->getLastName() : '',
511 - '{{company_name}}' => get_bloginfo('name'),
667 + '{{company_name}}' => get_option('easy_invoice_company_name') ?: get_bloginfo('name'),
512 668 '{{company_email}}' => $this->settings['from_email'],
513 669 '{{company_phone}}' => get_option('easy_invoice_company_phone', ''),
514 670 '{{company_address}}' => get_option('easy_invoice_company_address', ''),
515 671 '{{company_website}}' => get_option('easy_invoice_company_website', ''),
@@ -516,15 +672,15 @@
516 672 '{{total_amount}}' => (new \EasyInvoice\Helpers\QuoteFormatter($quote))->format($quote->getTotal()),
517 673 '{{subtotal}}' => (new \EasyInvoice\Helpers\QuoteFormatter($quote))->format($quote->getSubtotal()),
518 674 '{{tax_amount}}' => (new \EasyInvoice\Helpers\QuoteFormatter($quote))->format($quote->getTaxAmount()),
519 675 '{{discount_amount}}' => (new \EasyInvoice\Helpers\QuoteFormatter($quote))->format($quote->getDiscountAmount()),
520 - '{{expiry_date}}' => date('F j, Y', strtotime($quote->getExpiryDate())),
521 - '{{issue_date}}' => date('F j, Y', strtotime($quote->getIssueDate())),
676 + '{{expiry_date}}' => gmdate('F j, Y', strtotime($quote->getExpiryDate())),
677 + '{{issue_date}}' => gmdate('F j, Y', strtotime($quote->getIssueDate())),
522 678 '{{quote_url}}' => $quote_url,
523 679 '{{site_url}}' => get_site_url(),
524 680 '{{admin_url}}' => admin_url(),
525 681 '{{payment_terms}}' => get_option('easy_invoice_payment_terms', __('Due on receipt', 'easy-invoice')),
526 - ], $additional_data);
682 + ], self::placeholderKeysOnly($additional_data));
527 683 }
528 684
529 685 /**
530 686 * Get payment replacements
@@ -536,12 +692,16 @@
536 692 private function getPaymentReplacements(Invoice $invoice, array $payment_data = []): array {
537 693 $replacements = $this->getInvoiceReplacements($invoice, $payment_data);
538 694
539 695 // Add payment-specific replacements
540 - $replacements['{{payment_amount}}'] = isset($payment_data['amount']) ? $this->formatCurrency($payment_data['amount']) : $this->formatCurrency($invoice->getTotal());
541 - $replacements['{{payment_date}}'] = isset($payment_data['date']) ? $payment_data['date'] : current_time('Y-m-d');
542 - $replacements['{{payment_method}}'] = isset($payment_data['method']) ? $payment_data['method'] : __('Online Payment', 'easy-invoice');
543 - $replacements['{{transaction_id}}'] = isset($payment_data['transaction_id']) ? $payment_data['transaction_id'] : __('N/A', 'easy-invoice');
696 + $formatter = new \EasyInvoice\Helpers\InvoiceFormatter($invoice);
697 + $replacements['{{payment_amount}}'] = isset($payment_data['amount']) ? $formatter->format((float) $payment_data['amount']) : $formatter->format($invoice->getTotal());
698 + $paid_on = !empty($payment_data['date']) ? strtotime((string) $payment_data['date']) : false;
699 + $replacements['{{payment_date}}'] = date_i18n(get_option('date_format'), $paid_on ?: current_time('timestamp'));
700 + // Callers pass the gateway id as payment_method (some as method); show its label.
701 + $method_key = (string) ($payment_data['payment_method'] ?? $payment_data['method'] ?? '');
702 + $replacements['{{payment_method}}'] = '' !== $method_key ? $this->getPaymentMethodLabel($method_key) : __('Online Payment', 'easy-invoice');
703 + $replacements['{{transaction_id}}'] = !empty($payment_data['transaction_id']) ? (string) $payment_data['transaction_id'] : __('N/A', 'easy-invoice');
544 704 $replacements['{{acceptance_date}}'] = isset($payment_data['acceptance_date']) ? $payment_data['acceptance_date'] : current_time('Y-m-d');
545 705 $replacements['{{response_date}}'] = isset($payment_data['response_date']) ? $payment_data['response_date'] : current_time('Y-m-d');
546 706 $replacements['{{decline_reason}}'] = isset($payment_data['decline_reason']) ? $payment_data['decline_reason'] : __('No specific reason provided', 'easy-invoice');
547 707
@@ -563,9 +723,33 @@
563 723 * Prepare email headers
564 724 *
565 725 * @return array Headers
566 726 */
567 - private function prepareEmailHeaders(): array {
727 + /**
728 + * Should outgoing invoice emails carry a PDF copy?
729 + *
730 + * Defaults to off. Attaching a document changes what every customer receives and
731 + * makes messages substantially larger, which some SMTP relays limit — that is the
732 + * site owner's decision, not something an update should impose.
733 + *
734 + * @return bool
735 + */
736 + private function shouldAttachInvoicePdf(): bool {
737 + if (!\EasyInvoice\Services\PdfRenderer::isAvailable()) {
738 + return false;
739 + }
740 +
741 + $enabled = get_option('easy_invoice_attach_pdf_to_email', 'no') === 'yes';
742 +
743 + /**
744 + * Filter whether to attach a PDF to invoice emails.
745 + *
746 + * @param bool $enabled Current setting.
747 + */
748 + return (bool) apply_filters('easy_invoice_attach_pdf_to_email', $enabled);
749 + }
750 +
751 + private function prepareEmailHeaders(string $template_name = '', $document = null): array {
568 752 $headers = [
569 753 'Content-Type: text/html; charset=UTF-8',
570 754 'From: ' . $this->settings['from_name'] . ' <' . $this->settings['from_email'] . '>',
571 755 ];
@@ -574,10 +758,25 @@
574 758 if (!empty($this->settings['reply_to_email'])) {
575 759 $reply_to_name = !empty($this->settings['reply_to_name']) ? $this->settings['reply_to_name'] : $this->settings['from_name'];
576 760 $headers[] = 'Reply-To: ' . $reply_to_name . ' <' . $this->settings['reply_to_email'] . '>';
577 761 }
578 -
579 - return $headers;
762 +
763 + /**
764 + * Filter the headers of an outgoing Easy Invoice email.
765 + *
766 + * This is the extension point Easy Invoice Pro's Email Enhancements addon uses
767 + * to set a per-document-type Reply-To. The addon has always registered against
768 + * it, but nothing here ever applied it, so that half of the addon did nothing
769 + * at all — the Reply-To customers saw came only from the free plugin's own
770 + * Email settings above.
771 + *
772 + * @param array $headers Headers assembled so far.
773 + * @param string $template_name Which email this is: invoice, quote, receipt,
774 + * reminder, and so on. Empty when the caller has
775 + * no template context.
776 + * @param mixed $document The Invoice or Quote the email concerns, or null.
777 + */
778 + return (array) apply_filters('easy_invoice_email_headers', $headers, $template_name, $document);
580 779 }
581 780
582 781 /**
583 782 * Wrap message in HTML template
@@ -584,8 +783,69 @@
584 783 *
585 784 * @param string $message The message
586 785 * @return string HTML wrapped message
587 786 */
787 + /**
788 + * Wrap a message body in the plugin's HTML email layout (logo, styles,
789 + * footer) — for anything outside this class that sends a branded email.
790 + *
791 + * @param string $message Body HTML.
792 + * @return string
793 + */
794 + /**
795 + * The stock templates mention an attached copy. When nothing is attached
796 + * (the setting is off by default) that sentence would be untrue, so the
797 + * exact stock phrases are reworded; a merchant's own text is left alone.
798 + *
799 + * @param string $message Rendered email body.
800 + * @param bool $attached Whether a file goes with it.
801 + * @return string
802 + */
803 + private function attachmentWording(string $message, bool $attached): string {
804 + if ($attached) {
805 + return $message;
806 + }
807 + return str_replace(
808 + [
809 + __('The invoice is attached and can also be viewed and paid online:', 'easy-invoice'),
810 + __('It is attached, and you can review, accept or decline it online:', 'easy-invoice'),
811 + ],
812 + [
813 + __('You can view and pay it online:', 'easy-invoice'),
814 + __('You can review, accept or decline it online:', 'easy-invoice'),
815 + ],
816 + $message
817 + );
818 + }
819 +
820 + public function wrapMessage(string $message): string {
821 + return $this->wrapInHtmlTemplate($message);
822 + }
823 +
824 + /**
825 + * Headers for an email sent by something other than this class (Pro's
826 + * reminders, addons): From and Reply-To from Settings → Email, then the
827 + * `easy_invoice_email_headers` filter with the template name.
828 + *
829 + * @param string $template_name invoice, quote, receipt, reminder…
830 + * @param mixed $document The Invoice or Quote concerned, or null.
831 + * @return array<int,string>
832 + */
833 + public function headers(string $template_name = '', $document = null): array {
834 + return $this->prepareEmailHeaders($template_name, $document);
835 + }
836 +
837 + /**
838 + * Placeholder replacements for an invoice, for a template sent by
839 + * something other than this class (Pro's reminders, addons).
840 + *
841 + * @param object $invoice Invoice model.
842 + * @return array<string,string>
843 + */
844 + public function invoicePlaceholders($invoice): array {
845 + return $this->getInvoiceReplacements($invoice);
846 + }
847 +
588 848 private function wrapInHtmlTemplate(string $message): string {
589 849 $logo_html = '';
590 850 if (!empty($this->settings['email_logo'])) {
591 851 $logo_html = '<div style="text-align: center; margin-bottom: 40px;"><img src="' . esc_url($this->settings['email_logo']) . '" alt="' . esc_attr($this->settings['from_name']) . '" style="max-width: 200px; height: auto; border-radius: 8px;"></div>';
@@ -594,8 +854,34 @@
594 854 $footer_html = '';
595 855 if (!empty($this->settings['footer_text'])) {
596 856 $footer_html = '<div style="margin-top: 50px; padding-top: 25px; border-top: 2px solid #f3f4f6; font-size: 14px; color: #6b7280; text-align: center;">' . wpautop($this->settings['footer_text']) . '</div>';
597 857 }
858 +
859 + /**
860 + * Filter the footer block of every Easy Invoice email.
861 + *
862 + * @param string $footer_html The footer markup ('' when no footer text is set).
863 + * @param array $settings Email settings.
864 + */
865 + $footer_html = (string) apply_filters('easy_invoice_email_footer_html', $footer_html, $this->settings);
866 +
867 + /**
868 + * Replace the whole email layout.
869 + *
870 + * Return a full HTML document to use it instead of the stock layout.
871 + * Pro's Email Enhancements addon uses this for a custom branded
872 + * layout; the placeholders it offers are resolved before this fires.
873 + *
874 + * @param string $html '' — return non-empty markup to take over.
875 + * @param string $message The email body (placeholders already replaced), unwrapped.
876 + * @param string $logo_html Logo block from Settings → Email, or ''.
877 + * @param string $footer_html Footer block, after the filter above.
878 + * @param array $settings Email settings.
879 + */
880 + $custom = (string) apply_filters('easy_invoice_email_html', '', $message, $logo_html, $footer_html, $this->settings);
881 + if ('' !== trim($custom)) {
882 + return $custom;
883 + }
598 884
599 885 return '
600 886 <!DOCTYPE html>
601 887 <html>
@@ -821,78 +1107,8 @@
821 1107 </html>';
822 1108 }
823 1109
824 1110 /**
825 - * Handle AJAX send invoice email
826 - */
827 - public function handleSendInvoiceEmail(): void {
828 - // Verify nonce
829 - if (!wp_verify_nonce($_POST['nonce'] ?? '', 'easy_invoice_send_invoice_email')) {
830 - wp_send_json_error(__('Security check failed', 'easy-invoice'));
831 - }
832 -
833 - // Get invoice ID
834 - $invoice_id = isset($_POST['invoice_id']) ? intval($_POST['invoice_id']) : 0;
835 - if (!$invoice_id) {
836 - wp_send_json_error(__('Invalid invoice ID', 'easy-invoice'));
837 - }
838 -
839 - // Get invoice
840 - $repository = \EasyInvoice\Providers\InvoiceServiceProvider::getInvoiceRepository();
841 - $invoice = $repository->find($invoice_id);
842 -
843 - if (!$invoice) {
844 - wp_send_json_error(__('Invoice not found', 'easy-invoice'));
845 - }
846 -
847 - // Send email
848 - $result = $this->sendInvoiceEmail($invoice, 'new');
849 -
850 - if ($result['success']) {
851 - wp_send_json_success($result['message']);
852 - } else {
853 - wp_send_json_error($result['message']);
854 - }
855 - }
856 -
857 - /**
858 - * Handle AJAX send quote email
859 - */
860 - public function handleSendQuoteEmail(): void {
861 - // Verify nonce
862 - if (!wp_verify_nonce($_POST['nonce'] ?? '', 'easy_invoice_send_quote_email')) {
863 - wp_send_json_error(__('Security check failed', 'easy-invoice'));
864 - }
865 -
866 - // Get quote ID
867 - $quote_id = isset($_POST['quote_id']) ? intval($_POST['quote_id']) : 0;
868 - if (!$quote_id) {
869 - wp_send_json_error(__('Invalid quote ID', 'easy-invoice'));
870 - }
871 -
872 - // Get quote
873 - $repository = \EasyInvoice\Providers\QuoteServiceProvider::getQuoteRepository();
874 - $quote = $repository->find($quote_id);
875 -
876 - if (!$quote) {
877 - wp_send_json_error(__('Quote not found', 'easy-invoice'));
878 - }
879 -
880 - // Send email
881 - $result = $this->sendQuoteEmail($quote, 'new');
882 -
883 - if ($result['success']) {
884 - // Log the quote email sent
885 - $quote_log_service = new \EasyInvoice\Services\QuoteLogService();
886 - $quote_log_service->logSent($quote_id, $quote->getCustomerEmail());
887 -
888 - wp_send_json_success($result['message']);
889 - } else {
890 - wp_send_json_error($result['message']);
891 - }
892 - }
893 -
894 - /**
895 1111 * Register email settings
896 1112 */
897 1113 public function registerEmailSettings(): void {
898 1114 // Email settings section
@@ -903,17 +1119,20 @@
903 1119 'easy_invoice_settings'
904 1120 );
905 1121
906 1122 // Register settings
907 - register_setting('easy_invoice_settings', 'easy_invoice_email_from_name');
908 - register_setting('easy_invoice_settings', 'easy_invoice_email_from_address');
909 - register_setting('easy_invoice_settings', 'easy_invoice_email_reply_to');
910 - register_setting('easy_invoice_settings', 'easy_invoice_email_reply_to_name');
911 - register_setting('easy_invoice_settings', 'easy_invoice_enable_email_styling');
912 - register_setting('easy_invoice_settings', 'easy_invoice_email_logo');
913 - register_setting('easy_invoice_settings', 'easy_invoice_email_footer_text');
914 - register_setting('easy_invoice_settings', 'easy_invoice_bcc_admin');
915 - register_setting('easy_invoice_settings', 'easy_invoice_admin_email');
1123 + $yes_no = static function ($value) {
1124 + return in_array((string) $value, ['yes', '1', 'on', 'true'], true) ? 'yes' : 'no';
1125 + };
1126 + register_setting('easy_invoice_settings', 'easy_invoice_email_from_name', ['sanitize_callback' => 'sanitize_text_field']);
1127 + register_setting('easy_invoice_settings', 'easy_invoice_email_from_address', ['sanitize_callback' => 'sanitize_email']);
1128 + register_setting('easy_invoice_settings', 'easy_invoice_email_reply_to', ['sanitize_callback' => 'sanitize_email']);
1129 + register_setting('easy_invoice_settings', 'easy_invoice_email_reply_to_name', ['sanitize_callback' => 'sanitize_text_field']);
1130 + register_setting('easy_invoice_settings', 'easy_invoice_enable_email_styling', ['sanitize_callback' => $yes_no]);
1131 + register_setting('easy_invoice_settings', 'easy_invoice_email_logo', ['sanitize_callback' => 'esc_url_raw']);
1132 + register_setting('easy_invoice_settings', 'easy_invoice_email_footer_text', ['sanitize_callback' => 'wp_kses_post']);
1133 + register_setting('easy_invoice_settings', 'easy_invoice_bcc_admin', ['sanitize_callback' => $yes_no]);
1134 + register_setting('easy_invoice_settings', 'easy_invoice_admin_email', ['sanitize_callback' => 'sanitize_email']);
916 1135
917 1136 // Add settings fields
918 1137 add_settings_field(
919 1138 'easy_invoice_email_from_name',
@@ -960,73 +1179,14 @@
960 1179 ['label_for' => 'easy_invoice_bcc_admin']
961 1180 );
962 1181 }
963 1182
964 - /**
965 - * Add email settings section
966 - *
967 - * @param array $sections Settings sections
968 - * @return array Modified sections
969 - */
970 - public function addEmailSettingsSection(array $sections): array {
971 - $sections['email'] = [
972 - 'title' => __('Email Settings', 'easy-invoice'),
973 - 'description' => __('Configure email sending options and templates', 'easy-invoice'),
974 - 'icon' => 'fas fa-envelope',
975 - 'fields' => [
976 - 'easy_invoice_email_from_name' => [
977 - 'label' => __('From Name', 'easy-invoice'),
978 - 'type' => 'text',
979 - 'default' => get_bloginfo('name'),
980 - 'col_span' => 'sm:col-span-3'
981 - ],
982 - 'easy_invoice_email_from_address' => [
983 - 'label' => __('From Email Address', 'easy-invoice'),
984 - 'type' => 'email',
985 - 'default' => get_bloginfo('admin_email'),
986 - 'col_span' => 'sm:col-span-3'
987 - ],
988 - 'easy_invoice_email_reply_to' => [
989 - 'label' => __('Reply-To Email', 'easy-invoice'),
990 - 'type' => 'email',
991 - 'default' => '',
992 - 'col_span' => 'sm:col-span-3'
993 - ],
994 - 'easy_invoice_enable_email_styling' => [
995 - 'label' => __('Enable HTML Emails', 'easy-invoice'),
996 - 'type' => 'checkbox',
997 - 'default' => 'yes',
998 - 'col_span' => 'sm:col-span-3'
999 - ],
1000 - 'easy_invoice_bcc_admin' => [
1001 - 'label' => __('BCC Admin on All Emails', 'easy-invoice'),
1002 - 'type' => 'checkbox',
1003 - 'default' => 'no',
1004 - 'col_span' => 'sm:col-span-3'
1005 - ],
1006 - 'easy_invoice_email_logo' => [
1007 - 'label' => __('Email Logo URL', 'easy-invoice'),
1008 - 'type' => 'url',
1009 - 'default' => '',
1010 - 'col_span' => 'sm:col-span-6'
1011 - ],
1012 - 'easy_invoice_email_footer_text' => [
1013 - 'label' => __('Email Footer Text', 'easy-invoice'),
1014 - 'type' => 'textarea',
1015 - 'default' => '',
1016 - 'col_span' => 'sm:col-span-6'
1017 - ],
1018 - ]
1019 - ];
1020 -
1021 - return $sections;
1022 - }
1023 1183
1024 1184 /**
1025 1185 * Email settings section callback
1026 1186 */
1027 1187 public function emailSettingsSectionCallback(): void {
1028 - echo '<p>' . __('Configure how emails are sent from Easy Invoice.', 'easy-invoice') . '</p>';
1188 + echo '<p>' . esc_html__('Configure how emails are sent from Easy Invoice.', 'easy-invoice') . '</p>';
1029 1189 }
1030 1190
1031 1191 /**
1032 1192 * Text field callback
@@ -1058,9 +1218,9 @@
1058 1218 public function checkboxFieldCallback(array $args): void {
1059 1219 $field_id = $args['label_for'];
1060 1220 $value = get_option($field_id, '');
1061 1221 echo '<input type="checkbox" id="' . esc_attr($field_id) . '" name="' . esc_attr($field_id) . '" value="yes"' . checked($value, 'yes', false) . '>';
1062 - echo '<span class="description">' . __('Enable this option', 'easy-invoice') . '</span>';
1222 + echo '<span class="description">' . esc_html__('Enable this option', 'easy-invoice') . '</span>';
1063 1223 }
1064 1224
1065 1225 /**
1066 1226 * Log email sent
@@ -1088,232 +1248,186 @@
1088 1248 * Get default invoice template
1089 1249 *
1090 1250 * @return string Template
1091 1251 */
1092 - private function getDefaultInvoiceTemplate(): string {
1093 - return '<h2>📄 Your Invoice is Ready</h2>
1252 + /**
1253 + * Replace the 2.3.x stock email bodies with the 2.4.0 ones — once, and
1254 + * only where the saved body is still the stock text (compared by its
1255 + * words, since the editor re-wraps markup on save). A body the site
1256 + * edited is left alone.
1257 + */
1258 + public static function refreshStockTemplates(): void {
1259 + if ( get_option( 'easy_invoice_email_stock_v240' ) ) {
1260 + return;
1261 + }
1262 + $old = [
1263 + 'invoice' => [ '83846ae6875ad4335976cc07140e56bc', 'b40d303fa4194c0da4257495fa9e138f' ],
1264 + 'quote' => [ '7f31d11b8368fce31daddb7cbace9fb1', 'f9b9c4ad911ac729a04e52e35d056968' ],
1265 + 'payment' => [ '52c9e647eac3d10ea39cf641e2bfc2b0' ],
1266 + ];
1267 + foreach ( $old as $kind => $fingerprints ) {
1268 + $key = 'easy_invoice_' . $kind . '_email_body';
1269 + $stored = get_option( $key, null );
1270 + if ( null === $stored || '' === $stored ) {
1271 + continue;
1272 + }
1273 + $words = preg_replace( '/[^A-Za-z0-9{}]/u', '', html_entity_decode( wp_strip_all_tags( stripslashes( (string) $stored ) ) ) );
1274 + if ( in_array( md5( (string) $words ), $fingerprints, true ) ) {
1275 + update_option( $key, self::defaultTemplate( $kind ) );
1276 + }
1277 + }
1278 + update_option( 'easy_invoice_email_stock_v240', 1, false );
1279 + }
1094 1280
1281 + /**
1282 + * The stock body for one of the emails, used wherever a default is needed
1283 + * (settings screen, activation seeding, sending when nothing is saved).
1284 + *
1285 + * @param string $kind invoice | reminder | payment | quote | quote_accepted | quote_declined
1286 + * @return string
1287 + */
1288 + public static function defaultTemplate( string $kind ): string {
1289 + switch ( $kind ) {
1290 + case 'reminder': return self::getDefaultReminderTemplate();
1291 + case 'payment': return self::getDefaultPaymentTemplate();
1292 + case 'quote': return self::getDefaultQuoteTemplate();
1293 + case 'quote_accepted': return self::getDefaultQuoteAcceptedTemplate();
1294 + case 'quote_declined': return self::getDefaultQuoteDeclinedTemplate();
1295 + default: return self::getDefaultInvoiceTemplate();
1296 + }
1297 + }
1298 +
1299 + private static function getDefaultInvoiceTemplate(): string {
1300 + return '<h2>Invoice {{invoice_number}}</h2>
1301 +
1095 1302 <p>Dear {{client_name}},</p>
1096 1303
1097 -<div class="highlight-box">
1098 - <p><strong>Invoice #{{invoice_number}}</strong><br>
1099 - <span class="amount-highlight">{{total_amount}}</span><br>
1100 - Due Date: <strong>{{due_date}}</strong></p>
1101 -</div>
1304 +<p>Please find invoice {{invoice_number}} for <strong>{{total_amount}}</strong>, due on <strong>{{due_date}}</strong>. The invoice is attached and can also be viewed and paid online:</p>
1102 1305
1103 -<p>Your invoice has been prepared and is ready for payment. You can view and download the complete invoice from the attachment or visit the link below.</p>
1306 +<p style="text-align:center;margin:28px 0;"><a class="button" href="{{invoice_url}}">View and pay invoice</a></p>
1307 +<p style="font-size:13px;color:#6b7280;word-break:break-all;">{{invoice_url}}</p>
1104 1308
1105 1309 <div class="info-box">
1106 - <p><strong>📋 Payment Details:</strong><br>
1107 - • Invoice Number: {{invoice_number}}<br>
1108 - • Total Amount: {{total_amount}}<br>
1109 - • Due Date: {{due_date}}<br>
1110 - • Payment Terms: {{payment_terms}}</p>
1310 + <p>Invoice number: {{invoice_number}}<br>
1311 + Amount due: {{amount_due}}<br>
1312 + Due date: {{due_date}}</p>
1111 1313 </div>
1112 1314
1113 -<div class="highlight-box">
1114 - <p><strong>🔗 View Invoice Online:</strong><br>
1115 - <a href="{{invoice_url}}" style="color: #3b82f6; text-decoration: underline;">{{invoice_url}}</a></p>
1116 - <p><strong>Shortcode:</strong> <code>[easy_invoice_url number="{{invoice_number}}" text="View Invoice"]</code></p>
1117 -</div>
1315 +<p>If you have any questions about this invoice, just reply to this email.</p>
1118 1316
1119 -<div class="warning-box">
1120 - <p><strong>⚠️ Important:</strong> Please ensure payment is received by the due date to avoid any late fees or service interruptions.</p>
1121 -</div>
1122 -
1123 -<p>If you have any questions about this invoice, please do not hesitate to contact us.</p>
1124 -
1125 1317 <div class="divider"></div>
1126 1318
1127 -<p>Thank you for your business!</p>
1319 +<p>Thank you for your business.</p>
1128 1320
1129 -<p>Best regards,<br>
1130 -<strong>{{company_name}}</strong><br>
1321 +<p>{{company_name}}<br>
1131 1322 {{company_email}}</p>';
1132 1323 }
1133 1324
1134 - private function getDefaultReminderTemplate(): string {
1135 - return '<h2>⏰ Payment Reminder</h2>
1325 + private static function getDefaultReminderTemplate(): string {
1326 + return '<h2>Payment reminder — invoice {{invoice_number}}</h2>
1136 1327
1137 1328 <p>Dear {{client_name}},</p>
1138 1329
1139 -<div class="warning-box">
1140 - <p><strong>Invoice #{{invoice_number}}</strong><br>
1141 - <span class="amount-highlight">{{total_amount}}</span><br>
1142 - Due Date: <strong>{{due_date}}</strong></p>
1143 -</div>
1330 +<p>A reminder that invoice {{invoice_number}} was due on <strong>{{due_date}}</strong>; <strong>{{amount_due}}</strong> is still outstanding. If you have already paid, please disregard this message.</p>
1144 1331
1145 -<p>This is a friendly reminder that payment for the above invoice is now due. If you have already made the payment, please disregard this message.</p>
1332 +<p style="text-align:center;margin:28px 0;"><a class="button" href="{{invoice_url}}">View and pay invoice</a></p>
1333 +<p style="font-size:13px;color:#6b7280;word-break:break-all;">{{invoice_url}}</p>
1146 1334
1147 -<div class="info-box">
1148 - <p><strong>💳 Payment Options:</strong><br>
1149 - • Online payment through our secure portal<br>
1150 - • Bank transfer to the details provided<br>
1151 - • Check or money order</p>
1152 -</div>
1335 +<p>If you have a question about the invoice or need to arrange payment, reply to this email and we will sort it out.</p>
1153 1336
1154 -<div class="highlight-box">
1155 - <p><strong>🔗 View Invoice Online:</strong><br>
1156 - <a href="{{invoice_url}}" style="color: #3b82f6; text-decoration: underline;">{{invoice_url}}</a></p>
1157 - <p><strong>Shortcode:</strong> <code>[easy_invoice_url number="{{invoice_number}}" text="View Invoice"]</code></p>
1158 -</div>
1337 +<div class="divider"></div>
1159 1338
1160 -<div class="highlight-box">
1161 - <p><strong>📞 Need Help?</strong> If you have any questions or need to discuss payment arrangements, please contact us immediately.</p>
1162 -</div>
1339 +<p>Thank you.</p>
1163 1340
1164 -<p>Thank you for your prompt attention to this matter.</p>
1165 -
1166 -<div class="divider"></div>
1167 -
1168 -<p>Best regards,<br>
1169 -<strong>{{company_name}}</strong><br>
1341 +<p>{{company_name}}<br>
1170 1342 {{company_email}}</p>';
1171 1343 }
1172 1344
1173 - private function getDefaultPaymentTemplate(): string {
1174 - return '<h2>✅ Payment Received - Thank You!</h2>
1345 + private static function getDefaultPaymentTemplate(): string {
1346 + return '<h2>Payment received — thank you</h2>
1175 1347
1176 1348 <p>Dear {{client_name}},</p>
1177 1349
1178 -<div class="success-box">
1179 - <p><strong>Payment Confirmation</strong><br>
1180 - Invoice #{{invoice_number}}<br>
1181 - <span class="amount-highlight">{{payment_amount}}</span><br>
1182 - Payment Date: <strong>{{payment_date}}</strong><br>
1183 - Payment Method: <strong>{{payment_method}}</strong></p>
1184 -</div>
1350 +<p>We have received your payment of <strong>{{payment_amount}}</strong> against invoice {{invoice_number}}.</p>
1185 1351
1186 -<p>We have successfully received your payment. Thank you for your prompt payment!</p>
1187 -
1188 1352 <div class="info-box">
1189 - <p><strong>📊 Payment Details:</strong><br>
1190 - • Invoice Number: {{invoice_number}}<br>
1191 - • Amount Paid: {{payment_amount}}<br>
1192 - • Payment Date: {{payment_date}}<br>
1193 - • Payment Method: {{payment_method}}<br>
1194 - • Transaction ID: {{transaction_id}}</p>
1353 + <p>Invoice: {{invoice_number}}<br>
1354 + Amount paid: {{payment_amount}}<br>
1355 + Date: {{payment_date}}<br>
1356 + Method: {{payment_method}}<br>
1357 + Reference: {{transaction_id}}<br>
1358 + Balance remaining: {{amount_due}}</p>
1195 1359 </div>
1196 1360
1197 -<div class="highlight-box">
1198 - <p><strong>🎉 Status: PAID</strong><br>
1199 - Your payment has been processed and your account is now up to date. We appreciate your business!</p>
1200 -</div>
1361 +<p>Keep this email as your receipt. If you need anything else, reply to this message.</p>
1201 1362
1202 -<p>If you have any questions about this payment or need a receipt, please don\'t hesitate to contact us.</p>
1203 -
1204 1363 <div class="divider"></div>
1205 1364
1206 -<p>Thank you for choosing our services!</p>
1365 +<p>Thank you for your business.</p>
1207 1366
1208 -<p>Best regards,<br>
1209 -<strong>{{company_name}}</strong><br>
1367 +<p>{{company_name}}<br>
1210 1368 {{company_email}}</p>';
1211 1369 }
1212 1370
1213 - private function getDefaultQuoteTemplate(): string {
1214 - return '<h2>📋 Your Quote is Ready</h2>
1371 + private static function getDefaultQuoteTemplate(): string {
1372 + return '<h2>Quote {{quote_number}}</h2>
1215 1373
1216 1374 <p>Dear {{client_name}},</p>
1217 1375
1218 -<div class="highlight-box">
1219 - <p><strong>Quote #{{quote_number}}</strong><br>
1220 - <span class="amount-highlight">{{total_amount}}</span><br>
1221 - Valid Until: <strong>{{expiry_date}}</strong></p>
1222 -</div>
1376 +<p>Please find our quote {{quote_number}} for <strong>{{total_amount}}</strong>, valid until <strong>{{expiry_date}}</strong>. It is attached, and you can review, accept or decline it online:</p>
1223 1377
1224 -<p>We have prepared a detailed quote for your project. You can view and download the complete quote from the attachment or visit the link below.</p>
1378 +<p style="text-align:center;margin:28px 0;"><a class="button" href="{{quote_url}}">View quote</a></p>
1379 +<p style="font-size:13px;color:#6b7280;word-break:break-all;">{{quote_url}}</p>
1225 1380
1226 1381 <div class="info-box">
1227 - <p><strong>📋 Quote Summary:</strong><br>
1228 - • Quote Number: {{quote_number}}<br>
1229 - • Total Amount: {{total_amount}}<br>
1230 - • Valid Until: {{expiry_date}}<br>
1231 - • Terms: {{payment_terms}}</p>
1382 + <p>Quote number: {{quote_number}}<br>
1383 + Amount: {{total_amount}}<br>
1384 + Valid until: {{expiry_date}}</p>
1232 1385 </div>
1233 1386
1234 -<div class="highlight-box">
1235 - <p><strong>🔗 View Quote Online:</strong><br>
1236 - <a href="{{quote_url}}" style="color: #3b82f6; text-decoration: underline;">{{quote_url}}</a></p>
1237 - <p><strong>Shortcode:</strong> <code>[easy_quote_url number="{{quote_number}}" text="View Quote"]</code></p>
1238 -</div>
1387 +<p>If you would like to discuss any part of it, just reply to this email.</p>
1239 1388
1240 -<div class="warning-box">
1241 - <p><strong>⏰ Time Sensitive:</strong> This quote is valid until {{expiry_date}}. Please review and respond within this timeframe.</p>
1242 -</div>
1243 -
1244 -<p>If you have any questions or would like to discuss any aspects of this quote, please contact us.</p>
1245 -
1246 1389 <div class="divider"></div>
1247 1390
1248 -<p>We look forward to working with you!</p>
1391 +<p>We look forward to working with you.</p>
1249 1392
1250 -<p>Best regards,<br>
1251 -<strong>{{company_name}}</strong><br>
1393 +<p>{{company_name}}<br>
1252 1394 {{company_email}}</p>';
1253 1395 }
1254 1396
1255 - private function getDefaultQuoteAcceptedTemplate(): string {
1256 - return '<h2>🎉 Quote Accepted - Project Confirmed!</h2>
1397 + private static function getDefaultQuoteAcceptedTemplate(): string {
1398 + return '<h2>Quote {{quote_number}} accepted</h2>
1257 1399
1258 1400 <p>Dear {{client_name}},</p>
1259 1401
1260 -<div class="success-box">
1261 - <p><strong>Quote #{{quote_number}} - ACCEPTED</strong><br>
1262 - <span class="amount-highlight">{{total_amount}}</span><br>
1263 - Acceptance Date: <strong>{{acceptance_date}}</strong></p>
1264 -</div>
1402 +<p>Thank you for accepting quote {{quote_number}} for <strong>{{total_amount}}</strong> on {{acceptance_date}}.</p>
1265 1403
1266 -<p>Thank you for accepting our quote! We\'re excited to begin working on your project.</p>
1404 +<p>We will send the invoice and any next steps shortly. If you have questions in the meantime, reply to this email.</p>
1267 1405
1268 -<div class="info-box">
1269 - <p><strong>🚀 Next Steps:</strong><br>
1270 - • We will create an invoice for the accepted quote<br>
1271 - • You will receive payment instructions<br>
1272 - • Project work will begin as scheduled</p>
1273 -</div>
1274 -
1275 -<div class="highlight-box">
1276 - <p><strong>📞 What\'s Next?</strong> Our team will be in touch shortly with the next steps and any additional information you may need.</p>
1277 -</div>
1278 -
1279 1406 <div class="divider"></div>
1280 1407
1281 -<p>Thank you for choosing our services!</p>
1408 +<p>Thank you for choosing us.</p>
1282 1409
1283 -<p>Best regards,<br>
1284 -<strong>{{company_name}}</strong><br>
1410 +<p>{{company_name}}<br>
1285 1411 {{company_email}}</p>';
1286 1412 }
1287 1413
1288 - private function getDefaultQuoteDeclinedTemplate(): string {
1289 - return '<h2>📝 Quote Response Received</h2>
1414 + private static function getDefaultQuoteDeclinedTemplate(): string {
1415 + return '<h2>Quote {{quote_number}}</h2>
1290 1416
1291 1417 <p>Dear {{client_name}},</p>
1292 1418
1293 -<div class="warning-box">
1294 - <p><strong>Quote #{{quote_number}} - DECLINED</strong><br>
1295 - Response Date: <strong>{{response_date}}</strong></p>
1296 -</div>
1419 +<p>Thank you for letting us know that quote {{quote_number}} is not going ahead ({{response_date}}).</p>
1297 1420
1298 -<p>We have received your response regarding our quote. We understand that this quote may not have met your current needs.</p>
1299 -
1300 1421 <div class="info-box">
1301 - <p><strong>📋 Feedback:</strong><br>
1302 - • Reason: {{decline_reason}}<br>
1303 - • Response Date: {{response_date}}</p>
1422 + <p>Reason given: {{decline_reason}}</p>
1304 1423 </div>
1305 1424
1306 -<div class="highlight-box">
1307 - <p><strong>🤝 Future Opportunities:</strong> We appreciate you taking the time to review our proposal. If your requirements change in the future, we would be happy to discuss new opportunities.</p>
1308 -</div>
1425 +<p>If your requirements change, or there is something we could adjust, we would be glad to prepare a revised quote — just reply to this email.</p>
1309 1426
1310 1427 <div class="divider"></div>
1311 1428
1312 -<p>Thank you for considering our services!</p>
1313 -
1314 -<p>Best regards,<br>
1315 -<strong>{{company_name}}</strong><br>
1429 +<p>{{company_name}}<br>
1316 1430 {{company_email}}</p>';
1317 1431 }
1318 1432
1319 1433 /**
@@ -1447,8 +1561,10 @@
1447 1561 return ['success' => false, 'message' => __('Payment email template not found', 'easy-invoice')];
1448 1562 }
1449 1563
1450 1564 $template = $this->templates[$template_key];
1565 + /** This filter is documented above in sendInvoiceEmail(). */
1566 + $template = (array) apply_filters('easy_invoice_email_template_data', $template, $template_key, $invoice);
1451 1567
1452 1568 // Check if email is enabled
1453 1569 if (!$template['enabled']) {
1454 1570 return ['success' => false, 'message' => __('Payment received email is disabled', 'easy-invoice')];
@@ -1464,8 +1580,17 @@
1464 1580 $email_data['message'],
1465 1581 $email_data['headers']
1466 1582 );
1467 1583
1584 + /**
1585 + * Fires once an email send has finished, whether or not it went out.
1586 + *
1587 + * @param object $document Invoice or Quote model.
1588 + * @param string $template_key Template key.
1589 + * @param bool $sent Whether wp_mail() accepted it.
1590 + */
1591 + do_action('easy_invoice_email_finished', $invoice, $template_key, (bool) $sent);
1592 +
1468 1593 if ($sent) {
1469 1594 // Log success
1470 1595 do_action('easy_invoice_payment_email_sent', $invoice, $client_email, $payment_data);
1471 1596
@@ -1512,13 +1637,16 @@
1512 1637 $payment_method_label = $this->getPaymentMethodLabel($payment_method);
1513 1638
1514 1639 // Format amount
1515 1640 $formatter = new \EasyInvoice\Helpers\InvoiceFormatter($invoice);
1516 - $amount = $formatter->format($invoice->getTotal());
1641 + // The payment that came in, not the invoice's face value.
1642 + $amount = $formatter->format(isset($payment_data['amount']) && (float) $payment_data['amount'] > 0 ? (float) $payment_data['amount'] : $invoice->getTotal());
1517 1643
1518 1644 // Prepare email subject
1645 + $pending = !empty($payment_data['pending']);
1519 1646 $subject = sprintf(
1520 - __('New Payment Received - Invoice #%s', 'easy-invoice'),
1647 + /* translators: %s: document number. */
1648 + $pending ? __('Payment awaiting verification - Invoice #%s', 'easy-invoice') : __('New Payment Received - Invoice #%s', 'easy-invoice'),
1521 1649 $invoice->getNumber()
1522 1650 );
1523 1651
1524 1652 // Prepare email message
@@ -1552,8 +1680,34 @@
1552 1680 }
1553 1681 }
1554 1682
1555 1683 /**
1684 + * Tell the admin a manual payment is waiting for verification.
1685 + *
1686 + * @param int $invoice_id The invoice paid.
1687 + * @param string $payment_method Gateway or payment type submitted.
1688 + */
1689 + public function handleManualPaymentSubmitted($invoice_id, $payment_method = 'manual'): void {
1690 + $post = get_post((int) $invoice_id);
1691 + if (!$post) {
1692 + return;
1693 + }
1694 + $invoice = new Invoice($post);
1695 + if (!$invoice->getId()) {
1696 + return;
1697 + }
1698 + $payment_data = [
1699 + 'payment_method' => (string) $payment_method,
1700 + 'pending' => true,
1701 + ];
1702 + $notes = get_post_meta($invoice->getId(), '_manual_payment_notes', true);
1703 + if ($notes) {
1704 + $payment_data['notes'] = $notes;
1705 + }
1706 + $this->sendAdminPaymentNotification($invoice, $payment_data);
1707 + }
1708 +
1709 + /**
1556 1710 * Send payment confirmation email to customer
1557 1711 *
1558 1712 * @param Invoice $invoice The invoice
1559 1713 * @param array $payment_data Payment data
@@ -1584,9 +1738,10 @@
1584 1738
1585 1739 // Prepare email subject
1586 1740 $site_name = get_bloginfo('name');
1587 1741 $subject = sprintf(
1588 - __('[%s] Payment Confirmed - Invoice #%s', 'easy-invoice'),
1742 + /* translators: %1$s: site name; %2$s: document number. */
1743 + __('[%1$s] Payment Confirmed - Invoice #%2$s', 'easy-invoice'),
1589 1744 $site_name,
1590 1745 $invoice->getNumber()
1591 1746 );
1592 1747
@@ -1650,9 +1805,10 @@
1650 1805
1651 1806 // Prepare email subject
1652 1807 $site_name = get_bloginfo('name');
1653 1808 $subject = sprintf(
1654 - __('[%s] Payment Rejected - Invoice #%s', 'easy-invoice'),
1809 + /* translators: %1$s: site name; %2$s: document number. */
1810 + __('[%1$s] Payment Rejected - Invoice #%2$s', 'easy-invoice'),
1655 1811 $site_name,
1656 1812 $invoice->getNumber()
1657 1813 );
1658 1814
@@ -1706,25 +1862,42 @@
1706 1862 $customer_name = $invoice->getCustomerName();
1707 1863 $customer_email = $invoice->getCustomerEmail();
1708 1864 $invoice_id = $invoice->getId();
1709 1865
1866 + $pending = !empty($payment_data['pending']);
1710 1867 $message = sprintf(
1711 - __('A new %s payment has been received for invoice #%s.', 'easy-invoice'),
1868 + /* translators: %1$s: payment method; %2$s: invoice number. */
1869 + $pending ? __('A %1$s payment has been submitted for invoice #%2$s and is waiting for your verification.', 'easy-invoice') : __('A new %1$s payment has been received for invoice #%2$s.', 'easy-invoice'),
1712 1870 $payment_method_label,
1713 1871 $invoice_number
1714 1872 );
1715 1873 $message .= "\n\n";
1874 + /* translators: . */
1716 1875 $message .= __('Invoice Details:', 'easy-invoice');
1717 1876 $message .= "\n";
1718 - $message .= sprintf(__('- Amount: %s', 'easy-invoice'), $amount);
1877 + /* translators: %s: amount. */
1878 + $message .= sprintf($pending ? __('- Amount submitted: %s', 'easy-invoice') : __('- Amount received: %s', 'easy-invoice'), $amount);
1719 1879 $message .= "\n";
1880 + $ei_due = \EasyInvoice\Services\InvoiceBalance::due($invoice);
1881 + /* translators: %s: amount. */
1882 + $message .= sprintf(__('- Still owed: %s', 'easy-invoice'), (new \EasyInvoice\Helpers\InvoiceFormatter($invoice))->format($ei_due));
1883 + $message .= "\n";
1884 + /* translators: %s: customer name. */
1720 1885 $message .= sprintf(__('- Customer: %s', 'easy-invoice'), $customer_name);
1721 1886 $message .= "\n";
1887 + /* translators: %s: customer email address. */
1722 1888 $message .= sprintf(__('- Email: %s', 'easy-invoice'), $customer_email);
1723 1889
1890 + if (!empty($payment_data['notes'])) {
1891 + $message .= "\n";
1892 + /* translators: %s: note left by the client. */
1893 + $message .= sprintf(__('- Client note: %s', 'easy-invoice'), $payment_data['notes']);
1894 + }
1895 +
1724 1896 // Add transaction ID if available
1725 1897 if (!empty($payment_data['transaction_id'])) {
1726 1898 $message .= "\n";
1899 + /* translators: %s: transaction id. */
1727 1900 $message .= sprintf(__('- Transaction ID: %s', 'easy-invoice'), $payment_data['transaction_id']);
1728 1901 }
1729 1902
1730 1903 $message .= "\n\n";
@@ -1749,12 +1922,15 @@
1749 1922 $invoice_number = $invoice->getNumber();
1750 1923 $site_name = get_bloginfo('name');
1751 1924 $company_name = get_option('easy_invoice_company_name', $site_name);
1752 1925
1926 + /* translators: %s: customer name. */
1927 + /* translators: %s: customer name. */
1753 1928 $message = sprintf(__('Dear %s,', 'easy-invoice'), $customer_name);
1754 1929 $message .= "\n\n";
1755 1930 $message .= sprintf(
1756 - __('We are pleased to confirm that your payment of %s for Invoice #%s has been received and processed successfully.', 'easy-invoice'),
1931 + /* translators: %1$s: amount paid; %2$s: invoice number. */
1932 + __('We are pleased to confirm that your payment of %1$s for Invoice #%2$s has been received and processed successfully.', 'easy-invoice'),
1757 1933 $formatted_amount,
1758 1934 $invoice_number
1759 1935 );
1760 1936 $message .= "\n\n";
@@ -1779,11 +1955,14 @@
1779 1955 $invoice_number = $invoice->getNumber();
1780 1956 $site_name = get_bloginfo('name');
1781 1957 $company_name = get_option('easy_invoice_company_name', $site_name);
1782 1958
1959 + /* translators: %s: customer name. */
1960 + /* translators: %s: customer name. */
1783 1961 $message = sprintf(__('Dear %s,', 'easy-invoice'), $customer_name);
1784 1962 $message .= "\n\n";
1785 1963 $message .= sprintf(
1964 + /* translators: %s: invoice number. */
1786 1965 __('We regret to inform you that your payment for Invoice #%s has been rejected.', 'easy-invoice'),
1787 1966 $invoice_number
1788 1967 );
1789 1968
@@ -1825,9 +2004,10 @@
1825 2004 }
1826 2005
1827 2006 // Prepare email subject
1828 2007 $subject = sprintf(
1829 - __('Quote %s has been %s', 'easy-invoice'),
2008 + /* translators: %1$s: document number; %2$s: value. */
2009 + __('Quote %1$s has been %2$s', 'easy-invoice'),
1830 2010 $quote->getNumber(),
1831 2011 $action === 'accepted' ? __('accepted', 'easy-invoice') : __('declined', 'easy-invoice')
1832 2012 );
1833 2013
@@ -1880,26 +2060,33 @@
1880 2060
1881 2061 $action_label = $action === 'accepted' ? __('accepted', 'easy-invoice') : __('declined', 'easy-invoice');
1882 2062 $date_label = $action === 'accepted' ? __('Accepted Date', 'easy-invoice') : __('Declined Date', 'easy-invoice');
1883 2063
2064 + /* translators: . */
1884 2065 $message = __('Hello,', 'easy-invoice');
1885 2066 $message .= "\n\n";
1886 2067 $message .= sprintf(
1887 - __('The quote %s for %s has been %s by the client.', 'easy-invoice'),
2068 + /* translators: %1$s: quote number; %2$s: quote title; %3$s: accepted or declined. */
2069 + __('The quote %1$s for %2$s has been %3$s by the client.', 'easy-invoice'),
1888 2070 $quote_number,
1889 2071 $customer_name,
1890 2072 $action_label
1891 2073 );
1892 2074 $message .= "\n\n";
2075 + /* translators: . */
1893 2076 $message .= __('Quote Details:', 'easy-invoice');
1894 2077 $message .= "\n";
2078 + /* translators: %s: quote number. */
1895 2079 $message .= sprintf(__('- Quote Number: %s', 'easy-invoice'), $quote_number);
1896 2080 $message .= "\n";
2081 + /* translators: %s: customer name. */
1897 2082 $message .= sprintf(__('- Client: %s', 'easy-invoice'), $customer_name);
1898 2083 $message .= "\n";
2084 + /* translators: %s: amount. */
1899 2085 $message .= sprintf(__('- Total Amount: %s', 'easy-invoice'), $formatted_amount);
1900 2086 $message .= "\n";
1901 - $message .= sprintf(__('- %s: %s', 'easy-invoice'), $date_label, date_i18n(get_option('date_format') . ' ' . get_option('time_format')));
2087 + /* translators: %1$s: label such as "Accepted on"; %2$s: date and time. */
2088 + $message .= sprintf(__('- %1$s: %2$s', 'easy-invoice'), $date_label, date_i18n(get_option('date_format') . ' ' . get_option('time_format')));
1902 2089 $message .= "\n\n";
1903 2090 $message .= __('You can view the quote at:', 'easy-invoice');
1904 2091 $message .= "\n";
1905 2092 $message .= get_permalink($quote->getId());
@@ -1927,11 +2114,13 @@
1927 2114
1928 2115 // Send admin notification
1929 2116 $this->sendAdminPaymentNotification($invoice, $payment_data);
1930 2117
1931 - // Send customer confirmation email (without BCC to admin since we already sent admin notification)
1932 - // We pass a flag to skip BCC for payment confirmations
1933 - $this->sendPaymentConfirmationEmail($invoice, array_merge($payment_data, ['skip_bcc' => true]));
2118 + // Send customer confirmation email using proper template system
2119 + // Check if payment email is enabled first
2120 + if (isset($this->templates['invoice_paid']) && $this->templates['invoice_paid']['enabled']) {
2121 + $this->sendInvoiceEmail($invoice, 'paid', array_merge($payment_data, ['skip_bcc' => true, 'payment_receipt' => true]));
2122 + }
1934 2123 }
1935 2124
1936 2125 /**
1937 2126 * Get payment method label
@@ -1941,9 +2130,15 @@
1941 2130 */
1942 2131 private function getPaymentMethodLabel(string $method): string {
1943 2132 $labels = [
1944 2133 'bank' => __('Bank Transfer', 'easy-invoice'),
2134 + 'bank_transfer' => __('Bank Transfer', 'easy-invoice'),
2135 + 'cash' => __('Cash', 'easy-invoice'),
2136 + 'check' => __('Cheque', 'easy-invoice'),
1945 2137 'cheque' => __('Cheque', 'easy-invoice'),
2138 + 'paystack' => __('Paystack', 'easy-invoice'),
2139 + 'moneris' => __('Moneris', 'easy-invoice'),
2140 + 'other' => __('Other', 'easy-invoice'),
1946 2141 'paypal' => __('PayPal', 'easy-invoice'),
1947 2142 'stripe' => __('Stripe', 'easy-invoice'),
1948 2143 'square' => __('Square', 'easy-invoice'),
1949 2144 'mollie' => __('Mollie', 'easy-invoice'),