PluginProbe
Easy Invoice – Invoice Generator, PDF Quotes & Payments / 2.4.1
Easy Invoice – Invoice Generator, PDF Quotes & Payments v2.4.1
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 +506 -253 2.3.42.4.1 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'];
@@ -435,9 +547,22 @@
435 547 if ($secure_url) {
436 548 $invoice_url = $secure_url;
437 549 }
438 550 }
439 -
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 +
440 565 // Get client data for additional fields
441 566 $client = null;
442 567 if ($invoice->getClientId()) {
443 568 $client_repository = new \EasyInvoice\Repositories\ClientRepository();
@@ -451,26 +576,46 @@
451 576 '{{client_email}}' => $invoice->getCustomerEmail(),
452 577 '{{client_address}}' => $invoice->getCustomerAddress(),
453 578 '{{client_first_name}}' => $client ? $client->getFirstName() : '',
454 579 '{{client_last_name}}' => $client ? $client->getLastName() : '',
455 - '{{company_name}}' => get_bloginfo('name'),
580 + '{{company_name}}' => get_option('easy_invoice_company_name') ?: get_bloginfo('name'),
456 581 '{{company_email}}' => $this->settings['from_email'],
457 582 '{{company_phone}}' => get_option('easy_invoice_company_phone', ''),
458 583 '{{company_address}}' => get_option('easy_invoice_company_address', ''),
459 584 '{{company_website}}' => get_option('easy_invoice_company_website', ''),
460 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)),
461 587 '{{subtotal}}' => (new \EasyInvoice\Helpers\InvoiceFormatter($invoice))->format($invoice->getSubtotal()),
462 588 '{{tax_amount}}' => (new \EasyInvoice\Helpers\InvoiceFormatter($invoice))->format($invoice->getTaxAmount()),
463 589 '{{discount_amount}}' => (new \EasyInvoice\Helpers\InvoiceFormatter($invoice))->format($invoice->getDiscountAmount()),
464 - '{{due_date}}' => date('F j, Y', strtotime($invoice->getDueDate())),
465 - '{{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())),
466 592 '{{invoice_url}}' => $invoice_url, // Use correct (possibly secure) link
467 593 '{{payment_url}}' => add_query_arg('payment', '1', get_permalink($invoice->getId())),
468 594 '{{site_url}}' => get_site_url(),
469 595 '{{admin_url}}' => admin_url(),
470 596 '{{payment_terms}}' => get_option('easy_invoice_payment_terms', __('Due on receipt', 'easy-invoice')),
471 - ], $additional_data);
597 + ], self::placeholderKeysOnly($additional_data));
472 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 + }
473 618
474 619 /**
475 620 * Get quote replacements
476 621 *
@@ -500,8 +645,9 @@
500 645 // headers is no worse than leaking the secure-link signature.
501 646 $quote_access_token = \EasyInvoice\Controllers\QuoteController::quoteAccessToken((int) $quote->getId());
502 647 if ($quote_access_token !== '' && $quote_url) {
503 648 $quote_url = add_query_arg('qk', $quote_access_token, $quote_url);
649 + \EasyInvoice\TemplateLoader::markKeyedLinkSent((int) $quote->getId());
504 650 }
505 651
506 652 // Get client data for additional fields
507 653 $client = null;
@@ -517,9 +663,9 @@
517 663 '{{client_email}}' => $quote->getCustomerEmail(),
518 664 '{{client_address}}' => $quote->getCustomerAddress(),
519 665 '{{client_first_name}}' => $client ? $client->getFirstName() : '',
520 666 '{{client_last_name}}' => $client ? $client->getLastName() : '',
521 - '{{company_name}}' => get_bloginfo('name'),
667 + '{{company_name}}' => get_option('easy_invoice_company_name') ?: get_bloginfo('name'),
522 668 '{{company_email}}' => $this->settings['from_email'],
523 669 '{{company_phone}}' => get_option('easy_invoice_company_phone', ''),
524 670 '{{company_address}}' => get_option('easy_invoice_company_address', ''),
525 671 '{{company_website}}' => get_option('easy_invoice_company_website', ''),
@@ -526,15 +672,15 @@
526 672 '{{total_amount}}' => (new \EasyInvoice\Helpers\QuoteFormatter($quote))->format($quote->getTotal()),
527 673 '{{subtotal}}' => (new \EasyInvoice\Helpers\QuoteFormatter($quote))->format($quote->getSubtotal()),
528 674 '{{tax_amount}}' => (new \EasyInvoice\Helpers\QuoteFormatter($quote))->format($quote->getTaxAmount()),
529 675 '{{discount_amount}}' => (new \EasyInvoice\Helpers\QuoteFormatter($quote))->format($quote->getDiscountAmount()),
530 - '{{expiry_date}}' => date('F j, Y', strtotime($quote->getExpiryDate())),
531 - '{{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())),
532 678 '{{quote_url}}' => $quote_url,
533 679 '{{site_url}}' => get_site_url(),
534 680 '{{admin_url}}' => admin_url(),
535 681 '{{payment_terms}}' => get_option('easy_invoice_payment_terms', __('Due on receipt', 'easy-invoice')),
536 - ], $additional_data);
682 + ], self::placeholderKeysOnly($additional_data));
537 683 }
538 684
539 685 /**
540 686 * Get payment replacements
@@ -546,12 +692,16 @@
546 692 private function getPaymentReplacements(Invoice $invoice, array $payment_data = []): array {
547 693 $replacements = $this->getInvoiceReplacements($invoice, $payment_data);
548 694
549 695 // Add payment-specific replacements
550 - $replacements['{{payment_amount}}'] = isset($payment_data['amount']) ? $this->formatCurrency($payment_data['amount']) : $this->formatCurrency($invoice->getTotal());
551 - $replacements['{{payment_date}}'] = isset($payment_data['date']) ? $payment_data['date'] : current_time('Y-m-d');
552 - $replacements['{{payment_method}}'] = isset($payment_data['method']) ? $payment_data['method'] : __('Online Payment', 'easy-invoice');
553 - $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');
554 704 $replacements['{{acceptance_date}}'] = isset($payment_data['acceptance_date']) ? $payment_data['acceptance_date'] : current_time('Y-m-d');
555 705 $replacements['{{response_date}}'] = isset($payment_data['response_date']) ? $payment_data['response_date'] : current_time('Y-m-d');
556 706 $replacements['{{decline_reason}}'] = isset($payment_data['decline_reason']) ? $payment_data['decline_reason'] : __('No specific reason provided', 'easy-invoice');
557 707
@@ -573,9 +723,33 @@
573 723 * Prepare email headers
574 724 *
575 725 * @return array Headers
576 726 */
577 - 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 {
578 752 $headers = [
579 753 'Content-Type: text/html; charset=UTF-8',
580 754 'From: ' . $this->settings['from_name'] . ' <' . $this->settings['from_email'] . '>',
581 755 ];
@@ -584,10 +758,25 @@
584 758 if (!empty($this->settings['reply_to_email'])) {
585 759 $reply_to_name = !empty($this->settings['reply_to_name']) ? $this->settings['reply_to_name'] : $this->settings['from_name'];
586 760 $headers[] = 'Reply-To: ' . $reply_to_name . ' <' . $this->settings['reply_to_email'] . '>';
587 761 }
588 -
589 - 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);
590 779 }
591 780
592 781 /**
593 782 * Wrap message in HTML template
@@ -594,8 +783,69 @@
594 783 *
595 784 * @param string $message The message
596 785 * @return string HTML wrapped message
597 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 +
598 848 private function wrapInHtmlTemplate(string $message): string {
599 849 $logo_html = '';
600 850 if (!empty($this->settings['email_logo'])) {
601 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>';
@@ -604,8 +854,34 @@
604 854 $footer_html = '';
605 855 if (!empty($this->settings['footer_text'])) {
606 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>';
607 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 + }
608 884
609 885 return '
610 886 <!DOCTYPE html>
611 887 <html>
@@ -843,17 +1119,20 @@
843 1119 'easy_invoice_settings'
844 1120 );
845 1121
846 1122 // Register settings
847 - register_setting('easy_invoice_settings', 'easy_invoice_email_from_name');
848 - register_setting('easy_invoice_settings', 'easy_invoice_email_from_address');
849 - register_setting('easy_invoice_settings', 'easy_invoice_email_reply_to');
850 - register_setting('easy_invoice_settings', 'easy_invoice_email_reply_to_name');
851 - register_setting('easy_invoice_settings', 'easy_invoice_enable_email_styling');
852 - register_setting('easy_invoice_settings', 'easy_invoice_email_logo');
853 - register_setting('easy_invoice_settings', 'easy_invoice_email_footer_text');
854 - register_setting('easy_invoice_settings', 'easy_invoice_bcc_admin');
855 - 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']);
856 1135
857 1136 // Add settings fields
858 1137 add_settings_field(
859 1138 'easy_invoice_email_from_name',
@@ -900,73 +1179,14 @@
900 1179 ['label_for' => 'easy_invoice_bcc_admin']
901 1180 );
902 1181 }
903 1182
904 - /**
905 - * Add email settings section
906 - *
907 - * @param array $sections Settings sections
908 - * @return array Modified sections
909 - */
910 - public function addEmailSettingsSection(array $sections): array {
911 - $sections['email'] = [
912 - 'title' => __('Email Settings', 'easy-invoice'),
913 - 'description' => __('Configure email sending options and templates', 'easy-invoice'),
914 - 'icon' => 'fas fa-envelope',
915 - 'fields' => [
916 - 'easy_invoice_email_from_name' => [
917 - 'label' => __('From Name', 'easy-invoice'),
918 - 'type' => 'text',
919 - 'default' => get_bloginfo('name'),
920 - 'col_span' => 'sm:col-span-3'
921 - ],
922 - 'easy_invoice_email_from_address' => [
923 - 'label' => __('From Email Address', 'easy-invoice'),
924 - 'type' => 'email',
925 - 'default' => get_bloginfo('admin_email'),
926 - 'col_span' => 'sm:col-span-3'
927 - ],
928 - 'easy_invoice_email_reply_to' => [
929 - 'label' => __('Reply-To Email', 'easy-invoice'),
930 - 'type' => 'email',
931 - 'default' => '',
932 - 'col_span' => 'sm:col-span-3'
933 - ],
934 - 'easy_invoice_enable_email_styling' => [
935 - 'label' => __('Enable HTML Emails', 'easy-invoice'),
936 - 'type' => 'checkbox',
937 - 'default' => 'yes',
938 - 'col_span' => 'sm:col-span-3'
939 - ],
940 - 'easy_invoice_bcc_admin' => [
941 - 'label' => __('BCC Admin on All Emails', 'easy-invoice'),
942 - 'type' => 'checkbox',
943 - 'default' => 'no',
944 - 'col_span' => 'sm:col-span-3'
945 - ],
946 - 'easy_invoice_email_logo' => [
947 - 'label' => __('Email Logo URL', 'easy-invoice'),
948 - 'type' => 'url',
949 - 'default' => '',
950 - 'col_span' => 'sm:col-span-6'
951 - ],
952 - 'easy_invoice_email_footer_text' => [
953 - 'label' => __('Email Footer Text', 'easy-invoice'),
954 - 'type' => 'textarea',
955 - 'default' => '',
956 - 'col_span' => 'sm:col-span-6'
957 - ],
958 - ]
959 - ];
960 -
961 - return $sections;
962 - }
963 1183
964 1184 /**
965 1185 * Email settings section callback
966 1186 */
967 1187 public function emailSettingsSectionCallback(): void {
968 - 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>';
969 1189 }
970 1190
971 1191 /**
972 1192 * Text field callback
@@ -998,9 +1218,9 @@
998 1218 public function checkboxFieldCallback(array $args): void {
999 1219 $field_id = $args['label_for'];
1000 1220 $value = get_option($field_id, '');
1001 1221 echo '<input type="checkbox" id="' . esc_attr($field_id) . '" name="' . esc_attr($field_id) . '" value="yes"' . checked($value, 'yes', false) . '>';
1002 - echo '<span class="description">' . __('Enable this option', 'easy-invoice') . '</span>';
1222 + echo '<span class="description">' . esc_html__('Enable this option', 'easy-invoice') . '</span>';
1003 1223 }
1004 1224
1005 1225 /**
1006 1226 * Log email sent
@@ -1028,232 +1248,186 @@
1028 1248 * Get default invoice template
1029 1249 *
1030 1250 * @return string Template
1031 1251 */
1032 - private function getDefaultInvoiceTemplate(): string {
1033 - 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 + }
1034 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 +
1035 1302 <p>Dear {{client_name}},</p>
1036 1303
1037 -<div class="highlight-box">
1038 - <p><strong>Invoice #{{invoice_number}}</strong><br>
1039 - <span class="amount-highlight">{{total_amount}}</span><br>
1040 - Due Date: <strong>{{due_date}}</strong></p>
1041 -</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>
1042 1305
1043 -<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>
1044 1308
1045 1309 <div class="info-box">
1046 - <p><strong>📋 Payment Details:</strong><br>
1047 - • Invoice Number: {{invoice_number}}<br>
1048 - • Total Amount: {{total_amount}}<br>
1049 - • Due Date: {{due_date}}<br>
1050 - • Payment Terms: {{payment_terms}}</p>
1310 + <p>Invoice number: {{invoice_number}}<br>
1311 + Amount due: {{amount_due}}<br>
1312 + Due date: {{due_date}}</p>
1051 1313 </div>
1052 1314
1053 -<div class="highlight-box">
1054 - <p><strong>🔗 View Invoice Online:</strong><br>
1055 - <a href="{{invoice_url}}" style="color: #3b82f6; text-decoration: underline;">{{invoice_url}}</a></p>
1056 - <p><strong>Shortcode:</strong> <code>[easy_invoice_url number="{{invoice_number}}" text="View Invoice"]</code></p>
1057 -</div>
1315 +<p>If you have any questions about this invoice, just reply to this email.</p>
1058 1316
1059 -<div class="warning-box">
1060 - <p><strong>⚠️ Important:</strong> Please ensure payment is received by the due date to avoid any late fees or service interruptions.</p>
1061 -</div>
1062 -
1063 -<p>If you have any questions about this invoice, please do not hesitate to contact us.</p>
1064 -
1065 1317 <div class="divider"></div>
1066 1318
1067 -<p>Thank you for your business!</p>
1319 +<p>Thank you for your business.</p>
1068 1320
1069 -<p>Best regards,<br>
1070 -<strong>{{company_name}}</strong><br>
1321 +<p>{{company_name}}<br>
1071 1322 {{company_email}}</p>';
1072 1323 }
1073 1324
1074 - private function getDefaultReminderTemplate(): string {
1075 - return '<h2>⏰ Payment Reminder</h2>
1325 + private static function getDefaultReminderTemplate(): string {
1326 + return '<h2>Payment reminder — invoice {{invoice_number}}</h2>
1076 1327
1077 1328 <p>Dear {{client_name}},</p>
1078 1329
1079 -<div class="warning-box">
1080 - <p><strong>Invoice #{{invoice_number}}</strong><br>
1081 - <span class="amount-highlight">{{total_amount}}</span><br>
1082 - Due Date: <strong>{{due_date}}</strong></p>
1083 -</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>
1084 1331
1085 -<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>
1086 1334
1087 -<div class="info-box">
1088 - <p><strong>💳 Payment Options:</strong><br>
1089 - • Online payment through our secure portal<br>
1090 - • Bank transfer to the details provided<br>
1091 - • Check or money order</p>
1092 -</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>
1093 1336
1094 -<div class="highlight-box">
1095 - <p><strong>🔗 View Invoice Online:</strong><br>
1096 - <a href="{{invoice_url}}" style="color: #3b82f6; text-decoration: underline;">{{invoice_url}}</a></p>
1097 - <p><strong>Shortcode:</strong> <code>[easy_invoice_url number="{{invoice_number}}" text="View Invoice"]</code></p>
1098 -</div>
1337 +<div class="divider"></div>
1099 1338
1100 -<div class="highlight-box">
1101 - <p><strong>📞 Need Help?</strong> If you have any questions or need to discuss payment arrangements, please contact us immediately.</p>
1102 -</div>
1339 +<p>Thank you.</p>
1103 1340
1104 -<p>Thank you for your prompt attention to this matter.</p>
1105 -
1106 -<div class="divider"></div>
1107 -
1108 -<p>Best regards,<br>
1109 -<strong>{{company_name}}</strong><br>
1341 +<p>{{company_name}}<br>
1110 1342 {{company_email}}</p>';
1111 1343 }
1112 1344
1113 - private function getDefaultPaymentTemplate(): string {
1114 - return '<h2>✅ Payment Received - Thank You!</h2>
1345 + private static function getDefaultPaymentTemplate(): string {
1346 + return '<h2>Payment received — thank you</h2>
1115 1347
1116 1348 <p>Dear {{client_name}},</p>
1117 1349
1118 -<div class="success-box">
1119 - <p><strong>Payment Confirmation</strong><br>
1120 - Invoice #{{invoice_number}}<br>
1121 - <span class="amount-highlight">{{payment_amount}}</span><br>
1122 - Payment Date: <strong>{{payment_date}}</strong><br>
1123 - Payment Method: <strong>{{payment_method}}</strong></p>
1124 -</div>
1350 +<p>We have received your payment of <strong>{{payment_amount}}</strong> against invoice {{invoice_number}}.</p>
1125 1351
1126 -<p>We have successfully received your payment. Thank you for your prompt payment!</p>
1127 -
1128 1352 <div class="info-box">
1129 - <p><strong>📊 Payment Details:</strong><br>
1130 - • Invoice Number: {{invoice_number}}<br>
1131 - • Amount Paid: {{payment_amount}}<br>
1132 - • Payment Date: {{payment_date}}<br>
1133 - • Payment Method: {{payment_method}}<br>
1134 - • 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>
1135 1359 </div>
1136 1360
1137 -<div class="highlight-box">
1138 - <p><strong>🎉 Status: PAID</strong><br>
1139 - Your payment has been processed and your account is now up to date. We appreciate your business!</p>
1140 -</div>
1361 +<p>Keep this email as your receipt. If you need anything else, reply to this message.</p>
1141 1362
1142 -<p>If you have any questions about this payment or need a receipt, please don\'t hesitate to contact us.</p>
1143 -
1144 1363 <div class="divider"></div>
1145 1364
1146 -<p>Thank you for choosing our services!</p>
1365 +<p>Thank you for your business.</p>
1147 1366
1148 -<p>Best regards,<br>
1149 -<strong>{{company_name}}</strong><br>
1367 +<p>{{company_name}}<br>
1150 1368 {{company_email}}</p>';
1151 1369 }
1152 1370
1153 - private function getDefaultQuoteTemplate(): string {
1154 - return '<h2>📋 Your Quote is Ready</h2>
1371 + private static function getDefaultQuoteTemplate(): string {
1372 + return '<h2>Quote {{quote_number}}</h2>
1155 1373
1156 1374 <p>Dear {{client_name}},</p>
1157 1375
1158 -<div class="highlight-box">
1159 - <p><strong>Quote #{{quote_number}}</strong><br>
1160 - <span class="amount-highlight">{{total_amount}}</span><br>
1161 - Valid Until: <strong>{{expiry_date}}</strong></p>
1162 -</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>
1163 1377
1164 -<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>
1165 1380
1166 1381 <div class="info-box">
1167 - <p><strong>📋 Quote Summary:</strong><br>
1168 - • Quote Number: {{quote_number}}<br>
1169 - • Total Amount: {{total_amount}}<br>
1170 - • Valid Until: {{expiry_date}}<br>
1171 - • Terms: {{payment_terms}}</p>
1382 + <p>Quote number: {{quote_number}}<br>
1383 + Amount: {{total_amount}}<br>
1384 + Valid until: {{expiry_date}}</p>
1172 1385 </div>
1173 1386
1174 -<div class="highlight-box">
1175 - <p><strong>🔗 View Quote Online:</strong><br>
1176 - <a href="{{quote_url}}" style="color: #3b82f6; text-decoration: underline;">{{quote_url}}</a></p>
1177 - <p><strong>Shortcode:</strong> <code>[easy_quote_url number="{{quote_number}}" text="View Quote"]</code></p>
1178 -</div>
1387 +<p>If you would like to discuss any part of it, just reply to this email.</p>
1179 1388
1180 -<div class="warning-box">
1181 - <p><strong>⏰ Time Sensitive:</strong> This quote is valid until {{expiry_date}}. Please review and respond within this timeframe.</p>
1182 -</div>
1183 -
1184 -<p>If you have any questions or would like to discuss any aspects of this quote, please contact us.</p>
1185 -
1186 1389 <div class="divider"></div>
1187 1390
1188 -<p>We look forward to working with you!</p>
1391 +<p>We look forward to working with you.</p>
1189 1392
1190 -<p>Best regards,<br>
1191 -<strong>{{company_name}}</strong><br>
1393 +<p>{{company_name}}<br>
1192 1394 {{company_email}}</p>';
1193 1395 }
1194 1396
1195 - private function getDefaultQuoteAcceptedTemplate(): string {
1196 - return '<h2>🎉 Quote Accepted - Project Confirmed!</h2>
1397 + private static function getDefaultQuoteAcceptedTemplate(): string {
1398 + return '<h2>Quote {{quote_number}} accepted</h2>
1197 1399
1198 1400 <p>Dear {{client_name}},</p>
1199 1401
1200 -<div class="success-box">
1201 - <p><strong>Quote #{{quote_number}} - ACCEPTED</strong><br>
1202 - <span class="amount-highlight">{{total_amount}}</span><br>
1203 - Acceptance Date: <strong>{{acceptance_date}}</strong></p>
1204 -</div>
1402 +<p>Thank you for accepting quote {{quote_number}} for <strong>{{total_amount}}</strong> on {{acceptance_date}}.</p>
1205 1403
1206 -<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>
1207 1405
1208 -<div class="info-box">
1209 - <p><strong>🚀 Next Steps:</strong><br>
1210 - • We will create an invoice for the accepted quote<br>
1211 - • You will receive payment instructions<br>
1212 - • Project work will begin as scheduled</p>
1213 -</div>
1214 -
1215 -<div class="highlight-box">
1216 - <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>
1217 -</div>
1218 -
1219 1406 <div class="divider"></div>
1220 1407
1221 -<p>Thank you for choosing our services!</p>
1408 +<p>Thank you for choosing us.</p>
1222 1409
1223 -<p>Best regards,<br>
1224 -<strong>{{company_name}}</strong><br>
1410 +<p>{{company_name}}<br>
1225 1411 {{company_email}}</p>';
1226 1412 }
1227 1413
1228 - private function getDefaultQuoteDeclinedTemplate(): string {
1229 - return '<h2>📝 Quote Response Received</h2>
1414 + private static function getDefaultQuoteDeclinedTemplate(): string {
1415 + return '<h2>Quote {{quote_number}}</h2>
1230 1416
1231 1417 <p>Dear {{client_name}},</p>
1232 1418
1233 -<div class="warning-box">
1234 - <p><strong>Quote #{{quote_number}} - DECLINED</strong><br>
1235 - Response Date: <strong>{{response_date}}</strong></p>
1236 -</div>
1419 +<p>Thank you for letting us know that quote {{quote_number}} is not going ahead ({{response_date}}).</p>
1237 1420
1238 -<p>We have received your response regarding our quote. We understand that this quote may not have met your current needs.</p>
1239 -
1240 1421 <div class="info-box">
1241 - <p><strong>📋 Feedback:</strong><br>
1242 - • Reason: {{decline_reason}}<br>
1243 - • Response Date: {{response_date}}</p>
1422 + <p>Reason given: {{decline_reason}}</p>
1244 1423 </div>
1245 1424
1246 -<div class="highlight-box">
1247 - <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>
1248 -</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>
1249 1426
1250 1427 <div class="divider"></div>
1251 1428
1252 -<p>Thank you for considering our services!</p>
1253 -
1254 -<p>Best regards,<br>
1255 -<strong>{{company_name}}</strong><br>
1429 +<p>{{company_name}}<br>
1256 1430 {{company_email}}</p>';
1257 1431 }
1258 1432
1259 1433 /**
@@ -1387,8 +1561,10 @@
1387 1561 return ['success' => false, 'message' => __('Payment email template not found', 'easy-invoice')];
1388 1562 }
1389 1563
1390 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);
1391 1567
1392 1568 // Check if email is enabled
1393 1569 if (!$template['enabled']) {
1394 1570 return ['success' => false, 'message' => __('Payment received email is disabled', 'easy-invoice')];
@@ -1404,8 +1580,17 @@
1404 1580 $email_data['message'],
1405 1581 $email_data['headers']
1406 1582 );
1407 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 +
1408 1593 if ($sent) {
1409 1594 // Log success
1410 1595 do_action('easy_invoice_payment_email_sent', $invoice, $client_email, $payment_data);
1411 1596
@@ -1452,13 +1637,16 @@
1452 1637 $payment_method_label = $this->getPaymentMethodLabel($payment_method);
1453 1638
1454 1639 // Format amount
1455 1640 $formatter = new \EasyInvoice\Helpers\InvoiceFormatter($invoice);
1456 - $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());
1457 1643
1458 1644 // Prepare email subject
1645 + $pending = !empty($payment_data['pending']);
1459 1646 $subject = sprintf(
1460 - __('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'),
1461 1649 $invoice->getNumber()
1462 1650 );
1463 1651
1464 1652 // Prepare email message
@@ -1492,8 +1680,34 @@
1492 1680 }
1493 1681 }
1494 1682
1495 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 + /**
1496 1710 * Send payment confirmation email to customer
1497 1711 *
1498 1712 * @param Invoice $invoice The invoice
1499 1713 * @param array $payment_data Payment data
@@ -1524,9 +1738,10 @@
1524 1738
1525 1739 // Prepare email subject
1526 1740 $site_name = get_bloginfo('name');
1527 1741 $subject = sprintf(
1528 - __('[%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'),
1529 1744 $site_name,
1530 1745 $invoice->getNumber()
1531 1746 );
1532 1747
@@ -1590,9 +1805,10 @@
1590 1805
1591 1806 // Prepare email subject
1592 1807 $site_name = get_bloginfo('name');
1593 1808 $subject = sprintf(
1594 - __('[%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'),
1595 1811 $site_name,
1596 1812 $invoice->getNumber()
1597 1813 );
1598 1814
@@ -1646,25 +1862,42 @@
1646 1862 $customer_name = $invoice->getCustomerName();
1647 1863 $customer_email = $invoice->getCustomerEmail();
1648 1864 $invoice_id = $invoice->getId();
1649 1865
1866 + $pending = !empty($payment_data['pending']);
1650 1867 $message = sprintf(
1651 - __('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'),
1652 1870 $payment_method_label,
1653 1871 $invoice_number
1654 1872 );
1655 1873 $message .= "\n\n";
1874 + /* translators: . */
1656 1875 $message .= __('Invoice Details:', 'easy-invoice');
1657 1876 $message .= "\n";
1658 - $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);
1659 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. */
1660 1885 $message .= sprintf(__('- Customer: %s', 'easy-invoice'), $customer_name);
1661 1886 $message .= "\n";
1887 + /* translators: %s: customer email address. */
1662 1888 $message .= sprintf(__('- Email: %s', 'easy-invoice'), $customer_email);
1663 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 +
1664 1896 // Add transaction ID if available
1665 1897 if (!empty($payment_data['transaction_id'])) {
1666 1898 $message .= "\n";
1899 + /* translators: %s: transaction id. */
1667 1900 $message .= sprintf(__('- Transaction ID: %s', 'easy-invoice'), $payment_data['transaction_id']);
1668 1901 }
1669 1902
1670 1903 $message .= "\n\n";
@@ -1689,12 +1922,15 @@
1689 1922 $invoice_number = $invoice->getNumber();
1690 1923 $site_name = get_bloginfo('name');
1691 1924 $company_name = get_option('easy_invoice_company_name', $site_name);
1692 1925
1926 + /* translators: %s: customer name. */
1927 + /* translators: %s: customer name. */
1693 1928 $message = sprintf(__('Dear %s,', 'easy-invoice'), $customer_name);
1694 1929 $message .= "\n\n";
1695 1930 $message .= sprintf(
1696 - __('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'),
1697 1933 $formatted_amount,
1698 1934 $invoice_number
1699 1935 );
1700 1936 $message .= "\n\n";
@@ -1719,11 +1955,14 @@
1719 1955 $invoice_number = $invoice->getNumber();
1720 1956 $site_name = get_bloginfo('name');
1721 1957 $company_name = get_option('easy_invoice_company_name', $site_name);
1722 1958
1959 + /* translators: %s: customer name. */
1960 + /* translators: %s: customer name. */
1723 1961 $message = sprintf(__('Dear %s,', 'easy-invoice'), $customer_name);
1724 1962 $message .= "\n\n";
1725 1963 $message .= sprintf(
1964 + /* translators: %s: invoice number. */
1726 1965 __('We regret to inform you that your payment for Invoice #%s has been rejected.', 'easy-invoice'),
1727 1966 $invoice_number
1728 1967 );
1729 1968
@@ -1765,9 +2004,10 @@
1765 2004 }
1766 2005
1767 2006 // Prepare email subject
1768 2007 $subject = sprintf(
1769 - __('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'),
1770 2010 $quote->getNumber(),
1771 2011 $action === 'accepted' ? __('accepted', 'easy-invoice') : __('declined', 'easy-invoice')
1772 2012 );
1773 2013
@@ -1820,26 +2060,33 @@
1820 2060
1821 2061 $action_label = $action === 'accepted' ? __('accepted', 'easy-invoice') : __('declined', 'easy-invoice');
1822 2062 $date_label = $action === 'accepted' ? __('Accepted Date', 'easy-invoice') : __('Declined Date', 'easy-invoice');
1823 2063
2064 + /* translators: . */
1824 2065 $message = __('Hello,', 'easy-invoice');
1825 2066 $message .= "\n\n";
1826 2067 $message .= sprintf(
1827 - __('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'),
1828 2070 $quote_number,
1829 2071 $customer_name,
1830 2072 $action_label
1831 2073 );
1832 2074 $message .= "\n\n";
2075 + /* translators: . */
1833 2076 $message .= __('Quote Details:', 'easy-invoice');
1834 2077 $message .= "\n";
2078 + /* translators: %s: quote number. */
1835 2079 $message .= sprintf(__('- Quote Number: %s', 'easy-invoice'), $quote_number);
1836 2080 $message .= "\n";
2081 + /* translators: %s: customer name. */
1837 2082 $message .= sprintf(__('- Client: %s', 'easy-invoice'), $customer_name);
1838 2083 $message .= "\n";
2084 + /* translators: %s: amount. */
1839 2085 $message .= sprintf(__('- Total Amount: %s', 'easy-invoice'), $formatted_amount);
1840 2086 $message .= "\n";
1841 - $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')));
1842 2089 $message .= "\n\n";
1843 2090 $message .= __('You can view the quote at:', 'easy-invoice');
1844 2091 $message .= "\n";
1845 2092 $message .= get_permalink($quote->getId());
@@ -1870,9 +2117,9 @@
1870 2117
1871 2118 // Send customer confirmation email using proper template system
1872 2119 // Check if payment email is enabled first
1873 2120 if (isset($this->templates['invoice_paid']) && $this->templates['invoice_paid']['enabled']) {
1874 - $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]));
1875 2122 }
1876 2123 }
1877 2124
1878 2125 /**
@@ -1883,9 +2130,15 @@
1883 2130 */
1884 2131 private function getPaymentMethodLabel(string $method): string {
1885 2132 $labels = [
1886 2133 'bank' => __('Bank Transfer', 'easy-invoice'),
2134 + 'bank_transfer' => __('Bank Transfer', 'easy-invoice'),
2135 + 'cash' => __('Cash', 'easy-invoice'),
2136 + 'check' => __('Cheque', 'easy-invoice'),
1887 2137 'cheque' => __('Cheque', 'easy-invoice'),
2138 + 'paystack' => __('Paystack', 'easy-invoice'),
2139 + 'moneris' => __('Moneris', 'easy-invoice'),
2140 + 'other' => __('Other', 'easy-invoice'),
1888 2141 'paypal' => __('PayPal', 'easy-invoice'),
1889 2142 'stripe' => __('Stripe', 'easy-invoice'),
1890 2143 'square' => __('Square', 'easy-invoice'),
1891 2144 'mollie' => __('Mollie', 'easy-invoice'),