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 +494 -252 2.3.82.4.0 View file →
@@ -84,9 +84,9 @@
84 84 // (published-document checks, single handler) to avoid duplicate nopriv callbacks.
85 85
86 86 // Add email settings to admin
87 87 add_action('admin_init', [$this, 'registerEmailSettings']);
88 - add_filter('easy_invoice_settings_sections', [$this, 'addEmailSettingsSection']);
88 + add_action('admin_init', [__CLASS__, 'refreshStockTemplates']);
89 89
90 90 // Refresh settings when they're updated
91 91 add_action('update_option_easy_invoice_email_from_name', [$this, 'refreshSettings']);
92 92 add_action('update_option_easy_invoice_email_from_address', [$this, 'refreshSettings']);
@@ -107,8 +107,16 @@
107 107 add_action('easy_invoice_email_failed', [$this, 'logEmailFailed'], 10, 3);
108 108
109 109 // Listen for payment completion to send admin notifications
110 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);
111 119 }
112 120
113 121 /**
114 122 * Load email settings
@@ -198,8 +206,21 @@
198 206 return ['success' => false, 'message' => __('Email template not found', 'easy-invoice')];
199 207 }
200 208
201 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);
202 223
203 224 // Check if email is enabled
204 225 if (!$template['enabled']) {
205 226 return ['success' => false, 'message' => __('Invoice available email is disabled', 'easy-invoice')];
@@ -208,18 +229,71 @@
208 229 // Prepare email data
209 230 $email_data = $this->prepareInvoiceEmailData($invoice, $template, $additional_data);
210 231
211 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 +
212 262 $sent = $this->sendEmail(
213 263 $email_data['to'],
214 264 $email_data['subject'],
215 265 $email_data['message'],
216 - $email_data['headers']
266 + $email_data['headers'],
267 + $attachments
217 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 + }
218 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 +
219 285 if ($sent) {
220 286 // Log success
221 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 + }
222 296
223 297 return [
224 298 'success' => true,
225 299 'message' => __('Email sent successfully', 'easy-invoice'),
@@ -265,8 +339,10 @@
265 339 return ['success' => false, 'message' => __('Email template not found', 'easy-invoice')];
266 340 }
267 341
268 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);
269 345
270 346 // Check if email is enabled
271 347 if (!$template['enabled']) {
272 348 return ['success' => false, 'message' => __('Quote available email is disabled', 'easy-invoice')];
@@ -273,8 +349,23 @@
273 349 }
274 350
275 351 // Prepare email data
276 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));
277 368
278 369 // Send email
279 370 $sent = $this->sendEmail(
280 371 $email_data['to'],
@@ -279,14 +370,33 @@
279 370 $sent = $this->sendEmail(
280 371 $email_data['to'],
281 372 $email_data['subject'],
282 373 $email_data['message'],
283 - $email_data['headers']
374 + $email_data['headers'],
375 + $attachments
284 376 );
377 + if ($attached_path !== '' && file_exists($attached_path)) {
378 + wp_delete_file($attached_path);
379 + }
285 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 +
286 390 if ($sent) {
287 391 // Log success
288 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 + }
289 399
290 400 return [
291 401 'success' => true,
292 402 'message' => __('Quote email sent successfully', 'easy-invoice'),
@@ -313,10 +423,12 @@
313 423 * @param array $additional_data Additional data
314 424 * @return array Email data
315 425 */
316 426 private function prepareInvoiceEmailData(Invoice $invoice, array $template, array $additional_data = []): array {
317 - // Get replacements
318 - $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);
319 431
320 432 // Process template
321 433 $subject = $this->processTemplate($template['subject'], $replacements);
322 434 $message = $this->processTemplate($template['body'], $replacements);
@@ -327,9 +439,9 @@
327 439 $message = $this->wrapInHtmlTemplate($message);
328 440 }
329 441
330 442 // Prepare headers
331 - $headers = $this->prepareEmailHeaders();
443 + $headers = $this->prepareEmailHeaders('invoice', $invoice);
332 444
333 445 // Add BCC to admin if enabled
334 446 if ($this->settings['bcc_admin'] === 'yes' && !empty($this->settings['admin_email'])) {
335 447 $headers[] = 'Bcc: ' . $this->settings['admin_email'];
@@ -364,9 +476,9 @@
364 476 $message = $this->wrapInHtmlTemplate($message);
365 477 }
366 478
367 479 // Prepare headers
368 - $headers = $this->prepareEmailHeaders();
480 + $headers = $this->prepareEmailHeaders('quote', $quote);
369 481
370 482 // Add BCC to admin if enabled
371 483 if ($this->settings['bcc_admin'] === 'yes' && !empty($this->settings['admin_email'])) {
372 484 $headers[] = 'Bcc: ' . $this->settings['admin_email'];
@@ -401,9 +513,9 @@
401 513 $message = $this->wrapInHtmlTemplate($message);
402 514 }
403 515
404 516 // Prepare headers
405 - $headers = $this->prepareEmailHeaders();
517 + $headers = $this->prepareEmailHeaders('receipt', $invoice);
406 518
407 519 // Add BCC to admin if enabled
408 520 if ($this->settings['bcc_admin'] === 'yes' && !empty($this->settings['admin_email'])) {
409 521 $headers[] = 'Bcc: ' . $this->settings['admin_email'];
@@ -445,8 +557,10 @@
445 557 // guard so a CSPRNG failure doesn't produce malformed `?ik=` URLs.
446 558 $invoice_access_token = \EasyInvoice\Controllers\InvoiceController::invoiceAccessToken((int) $invoice->getId());
447 559 if ($invoice_access_token !== '' && $invoice_url) {
448 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());
449 563 }
450 564
451 565 // Get client data for additional fields
452 566 $client = null;
@@ -462,26 +576,46 @@
462 576 '{{client_email}}' => $invoice->getCustomerEmail(),
463 577 '{{client_address}}' => $invoice->getCustomerAddress(),
464 578 '{{client_first_name}}' => $client ? $client->getFirstName() : '',
465 579 '{{client_last_name}}' => $client ? $client->getLastName() : '',
466 - '{{company_name}}' => get_bloginfo('name'),
580 + '{{company_name}}' => get_option('easy_invoice_company_name') ?: get_bloginfo('name'),
467 581 '{{company_email}}' => $this->settings['from_email'],
468 582 '{{company_phone}}' => get_option('easy_invoice_company_phone', ''),
469 583 '{{company_address}}' => get_option('easy_invoice_company_address', ''),
470 584 '{{company_website}}' => get_option('easy_invoice_company_website', ''),
471 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)),
472 587 '{{subtotal}}' => (new \EasyInvoice\Helpers\InvoiceFormatter($invoice))->format($invoice->getSubtotal()),
473 588 '{{tax_amount}}' => (new \EasyInvoice\Helpers\InvoiceFormatter($invoice))->format($invoice->getTaxAmount()),
474 589 '{{discount_amount}}' => (new \EasyInvoice\Helpers\InvoiceFormatter($invoice))->format($invoice->getDiscountAmount()),
475 - '{{due_date}}' => date('F j, Y', strtotime($invoice->getDueDate())),
476 - '{{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())),
477 592 '{{invoice_url}}' => $invoice_url, // Use correct (possibly secure) link
478 593 '{{payment_url}}' => add_query_arg('payment', '1', get_permalink($invoice->getId())),
479 594 '{{site_url}}' => get_site_url(),
480 595 '{{admin_url}}' => admin_url(),
481 596 '{{payment_terms}}' => get_option('easy_invoice_payment_terms', __('Due on receipt', 'easy-invoice')),
482 - ], $additional_data);
597 + ], self::placeholderKeysOnly($additional_data));
483 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 + }
484 618
485 619 /**
486 620 * Get quote replacements
487 621 *
@@ -511,8 +645,9 @@
511 645 // headers is no worse than leaking the secure-link signature.
512 646 $quote_access_token = \EasyInvoice\Controllers\QuoteController::quoteAccessToken((int) $quote->getId());
513 647 if ($quote_access_token !== '' && $quote_url) {
514 648 $quote_url = add_query_arg('qk', $quote_access_token, $quote_url);
649 + \EasyInvoice\TemplateLoader::markKeyedLinkSent((int) $quote->getId());
515 650 }
516 651
517 652 // Get client data for additional fields
518 653 $client = null;
@@ -528,9 +663,9 @@
528 663 '{{client_email}}' => $quote->getCustomerEmail(),
529 664 '{{client_address}}' => $quote->getCustomerAddress(),
530 665 '{{client_first_name}}' => $client ? $client->getFirstName() : '',
531 666 '{{client_last_name}}' => $client ? $client->getLastName() : '',
532 - '{{company_name}}' => get_bloginfo('name'),
667 + '{{company_name}}' => get_option('easy_invoice_company_name') ?: get_bloginfo('name'),
533 668 '{{company_email}}' => $this->settings['from_email'],
534 669 '{{company_phone}}' => get_option('easy_invoice_company_phone', ''),
535 670 '{{company_address}}' => get_option('easy_invoice_company_address', ''),
536 671 '{{company_website}}' => get_option('easy_invoice_company_website', ''),
@@ -537,15 +672,15 @@
537 672 '{{total_amount}}' => (new \EasyInvoice\Helpers\QuoteFormatter($quote))->format($quote->getTotal()),
538 673 '{{subtotal}}' => (new \EasyInvoice\Helpers\QuoteFormatter($quote))->format($quote->getSubtotal()),
539 674 '{{tax_amount}}' => (new \EasyInvoice\Helpers\QuoteFormatter($quote))->format($quote->getTaxAmount()),
540 675 '{{discount_amount}}' => (new \EasyInvoice\Helpers\QuoteFormatter($quote))->format($quote->getDiscountAmount()),
541 - '{{expiry_date}}' => date('F j, Y', strtotime($quote->getExpiryDate())),
542 - '{{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())),
543 678 '{{quote_url}}' => $quote_url,
544 679 '{{site_url}}' => get_site_url(),
545 680 '{{admin_url}}' => admin_url(),
546 681 '{{payment_terms}}' => get_option('easy_invoice_payment_terms', __('Due on receipt', 'easy-invoice')),
547 - ], $additional_data);
682 + ], self::placeholderKeysOnly($additional_data));
548 683 }
549 684
550 685 /**
551 686 * Get payment replacements
@@ -557,12 +692,16 @@
557 692 private function getPaymentReplacements(Invoice $invoice, array $payment_data = []): array {
558 693 $replacements = $this->getInvoiceReplacements($invoice, $payment_data);
559 694
560 695 // Add payment-specific replacements
561 - $replacements['{{payment_amount}}'] = isset($payment_data['amount']) ? $this->formatCurrency($payment_data['amount']) : $this->formatCurrency($invoice->getTotal());
562 - $replacements['{{payment_date}}'] = isset($payment_data['date']) ? $payment_data['date'] : current_time('Y-m-d');
563 - $replacements['{{payment_method}}'] = isset($payment_data['method']) ? $payment_data['method'] : __('Online Payment', 'easy-invoice');
564 - $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');
565 704 $replacements['{{acceptance_date}}'] = isset($payment_data['acceptance_date']) ? $payment_data['acceptance_date'] : current_time('Y-m-d');
566 705 $replacements['{{response_date}}'] = isset($payment_data['response_date']) ? $payment_data['response_date'] : current_time('Y-m-d');
567 706 $replacements['{{decline_reason}}'] = isset($payment_data['decline_reason']) ? $payment_data['decline_reason'] : __('No specific reason provided', 'easy-invoice');
568 707
@@ -584,9 +723,33 @@
584 723 * Prepare email headers
585 724 *
586 725 * @return array Headers
587 726 */
588 - 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 {
589 752 $headers = [
590 753 'Content-Type: text/html; charset=UTF-8',
591 754 'From: ' . $this->settings['from_name'] . ' <' . $this->settings['from_email'] . '>',
592 755 ];
@@ -595,10 +758,25 @@
595 758 if (!empty($this->settings['reply_to_email'])) {
596 759 $reply_to_name = !empty($this->settings['reply_to_name']) ? $this->settings['reply_to_name'] : $this->settings['from_name'];
597 760 $headers[] = 'Reply-To: ' . $reply_to_name . ' <' . $this->settings['reply_to_email'] . '>';
598 761 }
599 -
600 - 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);
601 779 }
602 780
603 781 /**
604 782 * Wrap message in HTML template
@@ -605,8 +783,69 @@
605 783 *
606 784 * @param string $message The message
607 785 * @return string HTML wrapped message
608 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 +
609 848 private function wrapInHtmlTemplate(string $message): string {
610 849 $logo_html = '';
611 850 if (!empty($this->settings['email_logo'])) {
612 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>';
@@ -615,8 +854,34 @@
615 854 $footer_html = '';
616 855 if (!empty($this->settings['footer_text'])) {
617 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>';
618 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 + }
619 884
620 885 return '
621 886 <!DOCTYPE html>
622 887 <html>
@@ -854,17 +1119,20 @@
854 1119 'easy_invoice_settings'
855 1120 );
856 1121
857 1122 // Register settings
858 - register_setting('easy_invoice_settings', 'easy_invoice_email_from_name');
859 - register_setting('easy_invoice_settings', 'easy_invoice_email_from_address');
860 - register_setting('easy_invoice_settings', 'easy_invoice_email_reply_to');
861 - register_setting('easy_invoice_settings', 'easy_invoice_email_reply_to_name');
862 - register_setting('easy_invoice_settings', 'easy_invoice_enable_email_styling');
863 - register_setting('easy_invoice_settings', 'easy_invoice_email_logo');
864 - register_setting('easy_invoice_settings', 'easy_invoice_email_footer_text');
865 - register_setting('easy_invoice_settings', 'easy_invoice_bcc_admin');
866 - 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']);
867 1135
868 1136 // Add settings fields
869 1137 add_settings_field(
870 1138 'easy_invoice_email_from_name',
@@ -911,73 +1179,14 @@
911 1179 ['label_for' => 'easy_invoice_bcc_admin']
912 1180 );
913 1181 }
914 1182
915 - /**
916 - * Add email settings section
917 - *
918 - * @param array $sections Settings sections
919 - * @return array Modified sections
920 - */
921 - public function addEmailSettingsSection(array $sections): array {
922 - $sections['email'] = [
923 - 'title' => __('Email Settings', 'easy-invoice'),
924 - 'description' => __('Configure email sending options and templates', 'easy-invoice'),
925 - 'icon' => 'fas fa-envelope',
926 - 'fields' => [
927 - 'easy_invoice_email_from_name' => [
928 - 'label' => __('From Name', 'easy-invoice'),
929 - 'type' => 'text',
930 - 'default' => get_bloginfo('name'),
931 - 'col_span' => 'sm:col-span-3'
932 - ],
933 - 'easy_invoice_email_from_address' => [
934 - 'label' => __('From Email Address', 'easy-invoice'),
935 - 'type' => 'email',
936 - 'default' => get_bloginfo('admin_email'),
937 - 'col_span' => 'sm:col-span-3'
938 - ],
939 - 'easy_invoice_email_reply_to' => [
940 - 'label' => __('Reply-To Email', 'easy-invoice'),
941 - 'type' => 'email',
942 - 'default' => '',
943 - 'col_span' => 'sm:col-span-3'
944 - ],
945 - 'easy_invoice_enable_email_styling' => [
946 - 'label' => __('Enable HTML Emails', 'easy-invoice'),
947 - 'type' => 'checkbox',
948 - 'default' => 'yes',
949 - 'col_span' => 'sm:col-span-3'
950 - ],
951 - 'easy_invoice_bcc_admin' => [
952 - 'label' => __('BCC Admin on All Emails', 'easy-invoice'),
953 - 'type' => 'checkbox',
954 - 'default' => 'no',
955 - 'col_span' => 'sm:col-span-3'
956 - ],
957 - 'easy_invoice_email_logo' => [
958 - 'label' => __('Email Logo URL', 'easy-invoice'),
959 - 'type' => 'url',
960 - 'default' => '',
961 - 'col_span' => 'sm:col-span-6'
962 - ],
963 - 'easy_invoice_email_footer_text' => [
964 - 'label' => __('Email Footer Text', 'easy-invoice'),
965 - 'type' => 'textarea',
966 - 'default' => '',
967 - 'col_span' => 'sm:col-span-6'
968 - ],
969 - ]
970 - ];
971 -
972 - return $sections;
973 - }
974 1183
975 1184 /**
976 1185 * Email settings section callback
977 1186 */
978 1187 public function emailSettingsSectionCallback(): void {
979 - 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>';
980 1189 }
981 1190
982 1191 /**
983 1192 * Text field callback
@@ -1009,9 +1218,9 @@
1009 1218 public function checkboxFieldCallback(array $args): void {
1010 1219 $field_id = $args['label_for'];
1011 1220 $value = get_option($field_id, '');
1012 1221 echo '<input type="checkbox" id="' . esc_attr($field_id) . '" name="' . esc_attr($field_id) . '" value="yes"' . checked($value, 'yes', false) . '>';
1013 - echo '<span class="description">' . __('Enable this option', 'easy-invoice') . '</span>';
1222 + echo '<span class="description">' . esc_html__('Enable this option', 'easy-invoice') . '</span>';
1014 1223 }
1015 1224
1016 1225 /**
1017 1226 * Log email sent
@@ -1039,232 +1248,186 @@
1039 1248 * Get default invoice template
1040 1249 *
1041 1250 * @return string Template
1042 1251 */
1043 - private function getDefaultInvoiceTemplate(): string {
1044 - 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 + }
1045 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 +
1046 1302 <p>Dear {{client_name}},</p>
1047 1303
1048 -<div class="highlight-box">
1049 - <p><strong>Invoice #{{invoice_number}}</strong><br>
1050 - <span class="amount-highlight">{{total_amount}}</span><br>
1051 - Due Date: <strong>{{due_date}}</strong></p>
1052 -</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>
1053 1305
1054 -<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>
1055 1308
1056 1309 <div class="info-box">
1057 - <p><strong>📋 Payment Details:</strong><br>
1058 - • Invoice Number: {{invoice_number}}<br>
1059 - • Total Amount: {{total_amount}}<br>
1060 - • Due Date: {{due_date}}<br>
1061 - • Payment Terms: {{payment_terms}}</p>
1310 + <p>Invoice number: {{invoice_number}}<br>
1311 + Amount due: {{amount_due}}<br>
1312 + Due date: {{due_date}}</p>
1062 1313 </div>
1063 1314
1064 -<div class="highlight-box">
1065 - <p><strong>🔗 View Invoice Online:</strong><br>
1066 - <a href="{{invoice_url}}" style="color: #3b82f6; text-decoration: underline;">{{invoice_url}}</a></p>
1067 - <p><strong>Shortcode:</strong> <code>[easy_invoice_url number="{{invoice_number}}" text="View Invoice"]</code></p>
1068 -</div>
1315 +<p>If you have any questions about this invoice, just reply to this email.</p>
1069 1316
1070 -<div class="warning-box">
1071 - <p><strong>⚠️ Important:</strong> Please ensure payment is received by the due date to avoid any late fees or service interruptions.</p>
1072 -</div>
1073 -
1074 -<p>If you have any questions about this invoice, please do not hesitate to contact us.</p>
1075 -
1076 1317 <div class="divider"></div>
1077 1318
1078 -<p>Thank you for your business!</p>
1319 +<p>Thank you for your business.</p>
1079 1320
1080 -<p>Best regards,<br>
1081 -<strong>{{company_name}}</strong><br>
1321 +<p>{{company_name}}<br>
1082 1322 {{company_email}}</p>';
1083 1323 }
1084 1324
1085 - private function getDefaultReminderTemplate(): string {
1086 - return '<h2>⏰ Payment Reminder</h2>
1325 + private static function getDefaultReminderTemplate(): string {
1326 + return '<h2>Payment reminder — invoice {{invoice_number}}</h2>
1087 1327
1088 1328 <p>Dear {{client_name}},</p>
1089 1329
1090 -<div class="warning-box">
1091 - <p><strong>Invoice #{{invoice_number}}</strong><br>
1092 - <span class="amount-highlight">{{total_amount}}</span><br>
1093 - Due Date: <strong>{{due_date}}</strong></p>
1094 -</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>
1095 1331
1096 -<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>
1097 1334
1098 -<div class="info-box">
1099 - <p><strong>💳 Payment Options:</strong><br>
1100 - • Online payment through our secure portal<br>
1101 - • Bank transfer to the details provided<br>
1102 - • Check or money order</p>
1103 -</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>
1104 1336
1105 -<div class="highlight-box">
1106 - <p><strong>🔗 View Invoice Online:</strong><br>
1107 - <a href="{{invoice_url}}" style="color: #3b82f6; text-decoration: underline;">{{invoice_url}}</a></p>
1108 - <p><strong>Shortcode:</strong> <code>[easy_invoice_url number="{{invoice_number}}" text="View Invoice"]</code></p>
1109 -</div>
1337 +<div class="divider"></div>
1110 1338
1111 -<div class="highlight-box">
1112 - <p><strong>📞 Need Help?</strong> If you have any questions or need to discuss payment arrangements, please contact us immediately.</p>
1113 -</div>
1339 +<p>Thank you.</p>
1114 1340
1115 -<p>Thank you for your prompt attention to this matter.</p>
1116 -
1117 -<div class="divider"></div>
1118 -
1119 -<p>Best regards,<br>
1120 -<strong>{{company_name}}</strong><br>
1341 +<p>{{company_name}}<br>
1121 1342 {{company_email}}</p>';
1122 1343 }
1123 1344
1124 - private function getDefaultPaymentTemplate(): string {
1125 - return '<h2>✅ Payment Received - Thank You!</h2>
1345 + private static function getDefaultPaymentTemplate(): string {
1346 + return '<h2>Payment received — thank you</h2>
1126 1347
1127 1348 <p>Dear {{client_name}},</p>
1128 1349
1129 -<div class="success-box">
1130 - <p><strong>Payment Confirmation</strong><br>
1131 - Invoice #{{invoice_number}}<br>
1132 - <span class="amount-highlight">{{payment_amount}}</span><br>
1133 - Payment Date: <strong>{{payment_date}}</strong><br>
1134 - Payment Method: <strong>{{payment_method}}</strong></p>
1135 -</div>
1350 +<p>We have received your payment of <strong>{{payment_amount}}</strong> against invoice {{invoice_number}}.</p>
1136 1351
1137 -<p>We have successfully received your payment. Thank you for your prompt payment!</p>
1138 -
1139 1352 <div class="info-box">
1140 - <p><strong>📊 Payment Details:</strong><br>
1141 - • Invoice Number: {{invoice_number}}<br>
1142 - • Amount Paid: {{payment_amount}}<br>
1143 - • Payment Date: {{payment_date}}<br>
1144 - • Payment Method: {{payment_method}}<br>
1145 - • 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>
1146 1359 </div>
1147 1360
1148 -<div class="highlight-box">
1149 - <p><strong>🎉 Status: PAID</strong><br>
1150 - Your payment has been processed and your account is now up to date. We appreciate your business!</p>
1151 -</div>
1361 +<p>Keep this email as your receipt. If you need anything else, reply to this message.</p>
1152 1362
1153 -<p>If you have any questions about this payment or need a receipt, please don\'t hesitate to contact us.</p>
1154 -
1155 1363 <div class="divider"></div>
1156 1364
1157 -<p>Thank you for choosing our services!</p>
1365 +<p>Thank you for your business.</p>
1158 1366
1159 -<p>Best regards,<br>
1160 -<strong>{{company_name}}</strong><br>
1367 +<p>{{company_name}}<br>
1161 1368 {{company_email}}</p>';
1162 1369 }
1163 1370
1164 - private function getDefaultQuoteTemplate(): string {
1165 - return '<h2>📋 Your Quote is Ready</h2>
1371 + private static function getDefaultQuoteTemplate(): string {
1372 + return '<h2>Quote {{quote_number}}</h2>
1166 1373
1167 1374 <p>Dear {{client_name}},</p>
1168 1375
1169 -<div class="highlight-box">
1170 - <p><strong>Quote #{{quote_number}}</strong><br>
1171 - <span class="amount-highlight">{{total_amount}}</span><br>
1172 - Valid Until: <strong>{{expiry_date}}</strong></p>
1173 -</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>
1174 1377
1175 -<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>
1176 1380
1177 1381 <div class="info-box">
1178 - <p><strong>📋 Quote Summary:</strong><br>
1179 - • Quote Number: {{quote_number}}<br>
1180 - • Total Amount: {{total_amount}}<br>
1181 - • Valid Until: {{expiry_date}}<br>
1182 - • Terms: {{payment_terms}}</p>
1382 + <p>Quote number: {{quote_number}}<br>
1383 + Amount: {{total_amount}}<br>
1384 + Valid until: {{expiry_date}}</p>
1183 1385 </div>
1184 1386
1185 -<div class="highlight-box">
1186 - <p><strong>🔗 View Quote Online:</strong><br>
1187 - <a href="{{quote_url}}" style="color: #3b82f6; text-decoration: underline;">{{quote_url}}</a></p>
1188 - <p><strong>Shortcode:</strong> <code>[easy_quote_url number="{{quote_number}}" text="View Quote"]</code></p>
1189 -</div>
1387 +<p>If you would like to discuss any part of it, just reply to this email.</p>
1190 1388
1191 -<div class="warning-box">
1192 - <p><strong>⏰ Time Sensitive:</strong> This quote is valid until {{expiry_date}}. Please review and respond within this timeframe.</p>
1193 -</div>
1194 -
1195 -<p>If you have any questions or would like to discuss any aspects of this quote, please contact us.</p>
1196 -
1197 1389 <div class="divider"></div>
1198 1390
1199 -<p>We look forward to working with you!</p>
1391 +<p>We look forward to working with you.</p>
1200 1392
1201 -<p>Best regards,<br>
1202 -<strong>{{company_name}}</strong><br>
1393 +<p>{{company_name}}<br>
1203 1394 {{company_email}}</p>';
1204 1395 }
1205 1396
1206 - private function getDefaultQuoteAcceptedTemplate(): string {
1207 - return '<h2>🎉 Quote Accepted - Project Confirmed!</h2>
1397 + private static function getDefaultQuoteAcceptedTemplate(): string {
1398 + return '<h2>Quote {{quote_number}} accepted</h2>
1208 1399
1209 1400 <p>Dear {{client_name}},</p>
1210 1401
1211 -<div class="success-box">
1212 - <p><strong>Quote #{{quote_number}} - ACCEPTED</strong><br>
1213 - <span class="amount-highlight">{{total_amount}}</span><br>
1214 - Acceptance Date: <strong>{{acceptance_date}}</strong></p>
1215 -</div>
1402 +<p>Thank you for accepting quote {{quote_number}} for <strong>{{total_amount}}</strong> on {{acceptance_date}}.</p>
1216 1403
1217 -<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>
1218 1405
1219 -<div class="info-box">
1220 - <p><strong>🚀 Next Steps:</strong><br>
1221 - • We will create an invoice for the accepted quote<br>
1222 - • You will receive payment instructions<br>
1223 - • Project work will begin as scheduled</p>
1224 -</div>
1225 -
1226 -<div class="highlight-box">
1227 - <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>
1228 -</div>
1229 -
1230 1406 <div class="divider"></div>
1231 1407
1232 -<p>Thank you for choosing our services!</p>
1408 +<p>Thank you for choosing us.</p>
1233 1409
1234 -<p>Best regards,<br>
1235 -<strong>{{company_name}}</strong><br>
1410 +<p>{{company_name}}<br>
1236 1411 {{company_email}}</p>';
1237 1412 }
1238 1413
1239 - private function getDefaultQuoteDeclinedTemplate(): string {
1240 - return '<h2>📝 Quote Response Received</h2>
1414 + private static function getDefaultQuoteDeclinedTemplate(): string {
1415 + return '<h2>Quote {{quote_number}}</h2>
1241 1416
1242 1417 <p>Dear {{client_name}},</p>
1243 1418
1244 -<div class="warning-box">
1245 - <p><strong>Quote #{{quote_number}} - DECLINED</strong><br>
1246 - Response Date: <strong>{{response_date}}</strong></p>
1247 -</div>
1419 +<p>Thank you for letting us know that quote {{quote_number}} is not going ahead ({{response_date}}).</p>
1248 1420
1249 -<p>We have received your response regarding our quote. We understand that this quote may not have met your current needs.</p>
1250 -
1251 1421 <div class="info-box">
1252 - <p><strong>📋 Feedback:</strong><br>
1253 - • Reason: {{decline_reason}}<br>
1254 - • Response Date: {{response_date}}</p>
1422 + <p>Reason given: {{decline_reason}}</p>
1255 1423 </div>
1256 1424
1257 -<div class="highlight-box">
1258 - <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>
1259 -</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>
1260 1426
1261 1427 <div class="divider"></div>
1262 1428
1263 -<p>Thank you for considering our services!</p>
1264 -
1265 -<p>Best regards,<br>
1266 -<strong>{{company_name}}</strong><br>
1429 +<p>{{company_name}}<br>
1267 1430 {{company_email}}</p>';
1268 1431 }
1269 1432
1270 1433 /**
@@ -1398,8 +1561,10 @@
1398 1561 return ['success' => false, 'message' => __('Payment email template not found', 'easy-invoice')];
1399 1562 }
1400 1563
1401 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);
1402 1567
1403 1568 // Check if email is enabled
1404 1569 if (!$template['enabled']) {
1405 1570 return ['success' => false, 'message' => __('Payment received email is disabled', 'easy-invoice')];
@@ -1415,8 +1580,17 @@
1415 1580 $email_data['message'],
1416 1581 $email_data['headers']
1417 1582 );
1418 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 +
1419 1593 if ($sent) {
1420 1594 // Log success
1421 1595 do_action('easy_invoice_payment_email_sent', $invoice, $client_email, $payment_data);
1422 1596
@@ -1463,13 +1637,16 @@
1463 1637 $payment_method_label = $this->getPaymentMethodLabel($payment_method);
1464 1638
1465 1639 // Format amount
1466 1640 $formatter = new \EasyInvoice\Helpers\InvoiceFormatter($invoice);
1467 - $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());
1468 1643
1469 1644 // Prepare email subject
1645 + $pending = !empty($payment_data['pending']);
1470 1646 $subject = sprintf(
1471 - __('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'),
1472 1649 $invoice->getNumber()
1473 1650 );
1474 1651
1475 1652 // Prepare email message
@@ -1503,8 +1680,34 @@
1503 1680 }
1504 1681 }
1505 1682
1506 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 + /**
1507 1710 * Send payment confirmation email to customer
1508 1711 *
1509 1712 * @param Invoice $invoice The invoice
1510 1713 * @param array $payment_data Payment data
@@ -1535,9 +1738,10 @@
1535 1738
1536 1739 // Prepare email subject
1537 1740 $site_name = get_bloginfo('name');
1538 1741 $subject = sprintf(
1539 - __('[%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'),
1540 1744 $site_name,
1541 1745 $invoice->getNumber()
1542 1746 );
1543 1747
@@ -1601,9 +1805,10 @@
1601 1805
1602 1806 // Prepare email subject
1603 1807 $site_name = get_bloginfo('name');
1604 1808 $subject = sprintf(
1605 - __('[%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'),
1606 1811 $site_name,
1607 1812 $invoice->getNumber()
1608 1813 );
1609 1814
@@ -1657,25 +1862,42 @@
1657 1862 $customer_name = $invoice->getCustomerName();
1658 1863 $customer_email = $invoice->getCustomerEmail();
1659 1864 $invoice_id = $invoice->getId();
1660 1865
1866 + $pending = !empty($payment_data['pending']);
1661 1867 $message = sprintf(
1662 - __('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'),
1663 1870 $payment_method_label,
1664 1871 $invoice_number
1665 1872 );
1666 1873 $message .= "\n\n";
1874 + /* translators: . */
1667 1875 $message .= __('Invoice Details:', 'easy-invoice');
1668 1876 $message .= "\n";
1669 - $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);
1670 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. */
1671 1885 $message .= sprintf(__('- Customer: %s', 'easy-invoice'), $customer_name);
1672 1886 $message .= "\n";
1887 + /* translators: %s: customer email address. */
1673 1888 $message .= sprintf(__('- Email: %s', 'easy-invoice'), $customer_email);
1674 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 +
1675 1896 // Add transaction ID if available
1676 1897 if (!empty($payment_data['transaction_id'])) {
1677 1898 $message .= "\n";
1899 + /* translators: %s: transaction id. */
1678 1900 $message .= sprintf(__('- Transaction ID: %s', 'easy-invoice'), $payment_data['transaction_id']);
1679 1901 }
1680 1902
1681 1903 $message .= "\n\n";
@@ -1700,12 +1922,15 @@
1700 1922 $invoice_number = $invoice->getNumber();
1701 1923 $site_name = get_bloginfo('name');
1702 1924 $company_name = get_option('easy_invoice_company_name', $site_name);
1703 1925
1926 + /* translators: %s: customer name. */
1927 + /* translators: %s: customer name. */
1704 1928 $message = sprintf(__('Dear %s,', 'easy-invoice'), $customer_name);
1705 1929 $message .= "\n\n";
1706 1930 $message .= sprintf(
1707 - __('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'),
1708 1933 $formatted_amount,
1709 1934 $invoice_number
1710 1935 );
1711 1936 $message .= "\n\n";
@@ -1730,11 +1955,14 @@
1730 1955 $invoice_number = $invoice->getNumber();
1731 1956 $site_name = get_bloginfo('name');
1732 1957 $company_name = get_option('easy_invoice_company_name', $site_name);
1733 1958
1959 + /* translators: %s: customer name. */
1960 + /* translators: %s: customer name. */
1734 1961 $message = sprintf(__('Dear %s,', 'easy-invoice'), $customer_name);
1735 1962 $message .= "\n\n";
1736 1963 $message .= sprintf(
1964 + /* translators: %s: invoice number. */
1737 1965 __('We regret to inform you that your payment for Invoice #%s has been rejected.', 'easy-invoice'),
1738 1966 $invoice_number
1739 1967 );
1740 1968
@@ -1776,9 +2004,10 @@
1776 2004 }
1777 2005
1778 2006 // Prepare email subject
1779 2007 $subject = sprintf(
1780 - __('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'),
1781 2010 $quote->getNumber(),
1782 2011 $action === 'accepted' ? __('accepted', 'easy-invoice') : __('declined', 'easy-invoice')
1783 2012 );
1784 2013
@@ -1831,26 +2060,33 @@
1831 2060
1832 2061 $action_label = $action === 'accepted' ? __('accepted', 'easy-invoice') : __('declined', 'easy-invoice');
1833 2062 $date_label = $action === 'accepted' ? __('Accepted Date', 'easy-invoice') : __('Declined Date', 'easy-invoice');
1834 2063
2064 + /* translators: . */
1835 2065 $message = __('Hello,', 'easy-invoice');
1836 2066 $message .= "\n\n";
1837 2067 $message .= sprintf(
1838 - __('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'),
1839 2070 $quote_number,
1840 2071 $customer_name,
1841 2072 $action_label
1842 2073 );
1843 2074 $message .= "\n\n";
2075 + /* translators: . */
1844 2076 $message .= __('Quote Details:', 'easy-invoice');
1845 2077 $message .= "\n";
2078 + /* translators: %s: quote number. */
1846 2079 $message .= sprintf(__('- Quote Number: %s', 'easy-invoice'), $quote_number);
1847 2080 $message .= "\n";
2081 + /* translators: %s: customer name. */
1848 2082 $message .= sprintf(__('- Client: %s', 'easy-invoice'), $customer_name);
1849 2083 $message .= "\n";
2084 + /* translators: %s: amount. */
1850 2085 $message .= sprintf(__('- Total Amount: %s', 'easy-invoice'), $formatted_amount);
1851 2086 $message .= "\n";
1852 - $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')));
1853 2089 $message .= "\n\n";
1854 2090 $message .= __('You can view the quote at:', 'easy-invoice');
1855 2091 $message .= "\n";
1856 2092 $message .= get_permalink($quote->getId());
@@ -1881,9 +2117,9 @@
1881 2117
1882 2118 // Send customer confirmation email using proper template system
1883 2119 // Check if payment email is enabled first
1884 2120 if (isset($this->templates['invoice_paid']) && $this->templates['invoice_paid']['enabled']) {
1885 - $this->sendInvoiceEmail($invoice, 'paid', array_merge($payment_data, ['skip_bcc' => true]));
2121 + $this->sendInvoiceEmail($invoice, 'paid', array_merge($payment_data, ['skip_bcc' => true, 'payment_receipt' => true]));
1886 2122 }
1887 2123 }
1888 2124
1889 2125 /**
@@ -1894,9 +2130,15 @@
1894 2130 */
1895 2131 private function getPaymentMethodLabel(string $method): string {
1896 2132 $labels = [
1897 2133 'bank' => __('Bank Transfer', 'easy-invoice'),
2134 + 'bank_transfer' => __('Bank Transfer', 'easy-invoice'),
2135 + 'cash' => __('Cash', 'easy-invoice'),
2136 + 'check' => __('Cheque', 'easy-invoice'),
1898 2137 'cheque' => __('Cheque', 'easy-invoice'),
2138 + 'paystack' => __('Paystack', 'easy-invoice'),
2139 + 'moneris' => __('Moneris', 'easy-invoice'),
2140 + 'other' => __('Other', 'easy-invoice'),
1899 2141 'paypal' => __('PayPal', 'easy-invoice'),
1900 2142 'stripe' => __('Stripe', 'easy-invoice'),
1901 2143 'square' => __('Square', 'easy-invoice'),
1902 2144 'mollie' => __('Mollie', 'easy-invoice'),