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 +523 -257 2.2.02.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'];
@@ -429,15 +541,28 @@
429 541
430 542 // Secure link support
431 543 $invoice_url = get_permalink($invoice->getId());
432 544 $secure_links_enabled = get_option('easy_invoice_pro_enable_secure_links', 'no') === 'yes';
433 - if ($secure_links_enabled && class_exists('\EasyInvoicePro\Controllers\PermalinkController')) {
434 - $secure_url = \EasyInvoicePro\Controllers\PermalinkController::getInvoiceSecureLinkUrl($invoice->getId());
545 + if ($secure_links_enabled && class_exists('\EasyInvoicePro\Addons\SecureLinks\Controllers\PermalinkController')) {
546 + $secure_url = \EasyInvoicePro\Addons\SecureLinks\Controllers\PermalinkController::getInvoiceSecureLinkUrl($invoice->getId());
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 *
@@ -482,14 +627,28 @@
482 627 $currency_symbol = get_option('easy_invoice_currency_symbol', '$');
483 628
484 629 $quote_url = get_permalink($quote->getId());
485 630 $secure_links_enabled = get_option('easy_invoice_pro_enable_secure_links', 'no') === 'yes';
486 - if ($secure_links_enabled && class_exists('\EasyInvoicePro\Controllers\PermalinkController')) {
487 - $secure_url = \EasyInvoicePro\Controllers\PermalinkController::getQuoteSecureLinkUrl($quote->getId());
631 + if ($secure_links_enabled && class_exists('\EasyInvoicePro\Addons\SecureLinks\Controllers\PermalinkController')) {
632 + $secure_url = \EasyInvoicePro\Addons\SecureLinks\Controllers\PermalinkController::getQuoteSecureLinkUrl($quote->getId());
488 633 if ($secure_url) {
489 634 $quote_url = $secure_url;
490 635 }
491 636 }
637 +
638 + // SECURITY (CVE-2026-9021): attach the per-quote access token so
639 + // the emailed recipient lands on a page that renders the
640 + // Accept/Decline UI and can submit either action without
641 + // authenticating. Without the token the public single-quote page
642 + // is read-only (no buttons, no nonce in DOM). Lazily generates
643 + // the token on first send. The query parameter name is
644 + // intentionally short ('qk') and opaque — leaking it via referer
645 + // headers is no worse than leaking the secure-link signature.
646 + $quote_access_token = \EasyInvoice\Controllers\QuoteController::quoteAccessToken((int) $quote->getId());
647 + if ($quote_access_token !== '' && $quote_url) {
648 + $quote_url = add_query_arg('qk', $quote_access_token, $quote_url);
649 + \EasyInvoice\TemplateLoader::markKeyedLinkSent((int) $quote->getId());
650 + }
492 651
493 652 // Get client data for additional fields
494 653 $client = null;
495 654 if ($quote->getClientId()) {
@@ -504,9 +663,9 @@
504 663 '{{client_email}}' => $quote->getCustomerEmail(),
505 664 '{{client_address}}' => $quote->getCustomerAddress(),
506 665 '{{client_first_name}}' => $client ? $client->getFirstName() : '',
507 666 '{{client_last_name}}' => $client ? $client->getLastName() : '',
508 - '{{company_name}}' => get_bloginfo('name'),
667 + '{{company_name}}' => get_option('easy_invoice_company_name') ?: get_bloginfo('name'),
509 668 '{{company_email}}' => $this->settings['from_email'],
510 669 '{{company_phone}}' => get_option('easy_invoice_company_phone', ''),
511 670 '{{company_address}}' => get_option('easy_invoice_company_address', ''),
512 671 '{{company_website}}' => get_option('easy_invoice_company_website', ''),
@@ -513,15 +672,15 @@
513 672 '{{total_amount}}' => (new \EasyInvoice\Helpers\QuoteFormatter($quote))->format($quote->getTotal()),
514 673 '{{subtotal}}' => (new \EasyInvoice\Helpers\QuoteFormatter($quote))->format($quote->getSubtotal()),
515 674 '{{tax_amount}}' => (new \EasyInvoice\Helpers\QuoteFormatter($quote))->format($quote->getTaxAmount()),
516 675 '{{discount_amount}}' => (new \EasyInvoice\Helpers\QuoteFormatter($quote))->format($quote->getDiscountAmount()),
517 - '{{expiry_date}}' => date('F j, Y', strtotime($quote->getExpiryDate())),
518 - '{{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())),
519 678 '{{quote_url}}' => $quote_url,
520 679 '{{site_url}}' => get_site_url(),
521 680 '{{admin_url}}' => admin_url(),
522 681 '{{payment_terms}}' => get_option('easy_invoice_payment_terms', __('Due on receipt', 'easy-invoice')),
523 - ], $additional_data);
682 + ], self::placeholderKeysOnly($additional_data));
524 683 }
525 684
526 685 /**
527 686 * Get payment replacements
@@ -533,12 +692,16 @@
533 692 private function getPaymentReplacements(Invoice $invoice, array $payment_data = []): array {
534 693 $replacements = $this->getInvoiceReplacements($invoice, $payment_data);
535 694
536 695 // Add payment-specific replacements
537 - $replacements['{{payment_amount}}'] = isset($payment_data['amount']) ? $this->formatCurrency($payment_data['amount']) : $this->formatCurrency($invoice->getTotal());
538 - $replacements['{{payment_date}}'] = isset($payment_data['date']) ? $payment_data['date'] : current_time('Y-m-d');
539 - $replacements['{{payment_method}}'] = isset($payment_data['method']) ? $payment_data['method'] : __('Online Payment', 'easy-invoice');
540 - $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');
541 704 $replacements['{{acceptance_date}}'] = isset($payment_data['acceptance_date']) ? $payment_data['acceptance_date'] : current_time('Y-m-d');
542 705 $replacements['{{response_date}}'] = isset($payment_data['response_date']) ? $payment_data['response_date'] : current_time('Y-m-d');
543 706 $replacements['{{decline_reason}}'] = isset($payment_data['decline_reason']) ? $payment_data['decline_reason'] : __('No specific reason provided', 'easy-invoice');
544 707
@@ -560,9 +723,33 @@
560 723 * Prepare email headers
561 724 *
562 725 * @return array Headers
563 726 */
564 - 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 {
565 752 $headers = [
566 753 'Content-Type: text/html; charset=UTF-8',
567 754 'From: ' . $this->settings['from_name'] . ' <' . $this->settings['from_email'] . '>',
568 755 ];
@@ -571,10 +758,25 @@
571 758 if (!empty($this->settings['reply_to_email'])) {
572 759 $reply_to_name = !empty($this->settings['reply_to_name']) ? $this->settings['reply_to_name'] : $this->settings['from_name'];
573 760 $headers[] = 'Reply-To: ' . $reply_to_name . ' <' . $this->settings['reply_to_email'] . '>';
574 761 }
575 -
576 - 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);
577 779 }
578 780
579 781 /**
580 782 * Wrap message in HTML template
@@ -581,8 +783,69 @@
581 783 *
582 784 * @param string $message The message
583 785 * @return string HTML wrapped message
584 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 +
585 848 private function wrapInHtmlTemplate(string $message): string {
586 849 $logo_html = '';
587 850 if (!empty($this->settings['email_logo'])) {
588 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>';
@@ -591,8 +854,34 @@
591 854 $footer_html = '';
592 855 if (!empty($this->settings['footer_text'])) {
593 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>';
594 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 + }
595 884
596 885 return '
597 886 <!DOCTYPE html>
598 887 <html>
@@ -830,17 +1119,20 @@
830 1119 'easy_invoice_settings'
831 1120 );
832 1121
833 1122 // Register settings
834 - register_setting('easy_invoice_settings', 'easy_invoice_email_from_name');
835 - register_setting('easy_invoice_settings', 'easy_invoice_email_from_address');
836 - register_setting('easy_invoice_settings', 'easy_invoice_email_reply_to');
837 - register_setting('easy_invoice_settings', 'easy_invoice_email_reply_to_name');
838 - register_setting('easy_invoice_settings', 'easy_invoice_enable_email_styling');
839 - register_setting('easy_invoice_settings', 'easy_invoice_email_logo');
840 - register_setting('easy_invoice_settings', 'easy_invoice_email_footer_text');
841 - register_setting('easy_invoice_settings', 'easy_invoice_bcc_admin');
842 - 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']);
843 1135
844 1136 // Add settings fields
845 1137 add_settings_field(
846 1138 'easy_invoice_email_from_name',
@@ -887,73 +1179,14 @@
887 1179 ['label_for' => 'easy_invoice_bcc_admin']
888 1180 );
889 1181 }
890 1182
891 - /**
892 - * Add email settings section
893 - *
894 - * @param array $sections Settings sections
895 - * @return array Modified sections
896 - */
897 - public function addEmailSettingsSection(array $sections): array {
898 - $sections['email'] = [
899 - 'title' => __('Email Settings', 'easy-invoice'),
900 - 'description' => __('Configure email sending options and templates', 'easy-invoice'),
901 - 'icon' => 'fas fa-envelope',
902 - 'fields' => [
903 - 'easy_invoice_email_from_name' => [
904 - 'label' => __('From Name', 'easy-invoice'),
905 - 'type' => 'text',
906 - 'default' => get_bloginfo('name'),
907 - 'col_span' => 'sm:col-span-3'
908 - ],
909 - 'easy_invoice_email_from_address' => [
910 - 'label' => __('From Email Address', 'easy-invoice'),
911 - 'type' => 'email',
912 - 'default' => get_bloginfo('admin_email'),
913 - 'col_span' => 'sm:col-span-3'
914 - ],
915 - 'easy_invoice_email_reply_to' => [
916 - 'label' => __('Reply-To Email', 'easy-invoice'),
917 - 'type' => 'email',
918 - 'default' => '',
919 - 'col_span' => 'sm:col-span-3'
920 - ],
921 - 'easy_invoice_enable_email_styling' => [
922 - 'label' => __('Enable HTML Emails', 'easy-invoice'),
923 - 'type' => 'checkbox',
924 - 'default' => 'yes',
925 - 'col_span' => 'sm:col-span-3'
926 - ],
927 - 'easy_invoice_bcc_admin' => [
928 - 'label' => __('BCC Admin on All Emails', 'easy-invoice'),
929 - 'type' => 'checkbox',
930 - 'default' => 'no',
931 - 'col_span' => 'sm:col-span-3'
932 - ],
933 - 'easy_invoice_email_logo' => [
934 - 'label' => __('Email Logo URL', 'easy-invoice'),
935 - 'type' => 'url',
936 - 'default' => '',
937 - 'col_span' => 'sm:col-span-6'
938 - ],
939 - 'easy_invoice_email_footer_text' => [
940 - 'label' => __('Email Footer Text', 'easy-invoice'),
941 - 'type' => 'textarea',
942 - 'default' => '',
943 - 'col_span' => 'sm:col-span-6'
944 - ],
945 - ]
946 - ];
947 -
948 - return $sections;
949 - }
950 1183
951 1184 /**
952 1185 * Email settings section callback
953 1186 */
954 1187 public function emailSettingsSectionCallback(): void {
955 - 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>';
956 1189 }
957 1190
958 1191 /**
959 1192 * Text field callback
@@ -985,9 +1218,9 @@
985 1218 public function checkboxFieldCallback(array $args): void {
986 1219 $field_id = $args['label_for'];
987 1220 $value = get_option($field_id, '');
988 1221 echo '<input type="checkbox" id="' . esc_attr($field_id) . '" name="' . esc_attr($field_id) . '" value="yes"' . checked($value, 'yes', false) . '>';
989 - echo '<span class="description">' . __('Enable this option', 'easy-invoice') . '</span>';
1222 + echo '<span class="description">' . esc_html__('Enable this option', 'easy-invoice') . '</span>';
990 1223 }
991 1224
992 1225 /**
993 1226 * Log email sent
@@ -1015,232 +1248,186 @@
1015 1248 * Get default invoice template
1016 1249 *
1017 1250 * @return string Template
1018 1251 */
1019 - private function getDefaultInvoiceTemplate(): string {
1020 - 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 + }
1021 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 +
1022 1302 <p>Dear {{client_name}},</p>
1023 1303
1024 -<div class="highlight-box">
1025 - <p><strong>Invoice #{{invoice_number}}</strong><br>
1026 - <span class="amount-highlight">{{total_amount}}</span><br>
1027 - Due Date: <strong>{{due_date}}</strong></p>
1028 -</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>
1029 1305
1030 -<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>
1031 1308
1032 1309 <div class="info-box">
1033 - <p><strong>📋 Payment Details:</strong><br>
1034 - • Invoice Number: {{invoice_number}}<br>
1035 - • Total Amount: {{total_amount}}<br>
1036 - • Due Date: {{due_date}}<br>
1037 - • Payment Terms: {{payment_terms}}</p>
1310 + <p>Invoice number: {{invoice_number}}<br>
1311 + Amount due: {{amount_due}}<br>
1312 + Due date: {{due_date}}</p>
1038 1313 </div>
1039 1314
1040 -<div class="highlight-box">
1041 - <p><strong>🔗 View Invoice Online:</strong><br>
1042 - <a href="{{invoice_url}}" style="color: #3b82f6; text-decoration: underline;">{{invoice_url}}</a></p>
1043 - <p><strong>Shortcode:</strong> <code>[easy_invoice_url number="{{invoice_number}}" text="View Invoice"]</code></p>
1044 -</div>
1315 +<p>If you have any questions about this invoice, just reply to this email.</p>
1045 1316
1046 -<div class="warning-box">
1047 - <p><strong>⚠️ Important:</strong> Please ensure payment is received by the due date to avoid any late fees or service interruptions.</p>
1048 -</div>
1049 -
1050 -<p>If you have any questions about this invoice, please do not hesitate to contact us.</p>
1051 -
1052 1317 <div class="divider"></div>
1053 1318
1054 -<p>Thank you for your business!</p>
1319 +<p>Thank you for your business.</p>
1055 1320
1056 -<p>Best regards,<br>
1057 -<strong>{{company_name}}</strong><br>
1321 +<p>{{company_name}}<br>
1058 1322 {{company_email}}</p>';
1059 1323 }
1060 1324
1061 - private function getDefaultReminderTemplate(): string {
1062 - return '<h2>⏰ Payment Reminder</h2>
1325 + private static function getDefaultReminderTemplate(): string {
1326 + return '<h2>Payment reminder — invoice {{invoice_number}}</h2>
1063 1327
1064 1328 <p>Dear {{client_name}},</p>
1065 1329
1066 -<div class="warning-box">
1067 - <p><strong>Invoice #{{invoice_number}}</strong><br>
1068 - <span class="amount-highlight">{{total_amount}}</span><br>
1069 - Due Date: <strong>{{due_date}}</strong></p>
1070 -</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>
1071 1331
1072 -<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>
1073 1334
1074 -<div class="info-box">
1075 - <p><strong>💳 Payment Options:</strong><br>
1076 - • Online payment through our secure portal<br>
1077 - • Bank transfer to the details provided<br>
1078 - • Check or money order</p>
1079 -</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>
1080 1336
1081 -<div class="highlight-box">
1082 - <p><strong>🔗 View Invoice Online:</strong><br>
1083 - <a href="{{invoice_url}}" style="color: #3b82f6; text-decoration: underline;">{{invoice_url}}</a></p>
1084 - <p><strong>Shortcode:</strong> <code>[easy_invoice_url number="{{invoice_number}}" text="View Invoice"]</code></p>
1085 -</div>
1337 +<div class="divider"></div>
1086 1338
1087 -<div class="highlight-box">
1088 - <p><strong>📞 Need Help?</strong> If you have any questions or need to discuss payment arrangements, please contact us immediately.</p>
1089 -</div>
1339 +<p>Thank you.</p>
1090 1340
1091 -<p>Thank you for your prompt attention to this matter.</p>
1092 -
1093 -<div class="divider"></div>
1094 -
1095 -<p>Best regards,<br>
1096 -<strong>{{company_name}}</strong><br>
1341 +<p>{{company_name}}<br>
1097 1342 {{company_email}}</p>';
1098 1343 }
1099 1344
1100 - private function getDefaultPaymentTemplate(): string {
1101 - return '<h2>✅ Payment Received - Thank You!</h2>
1345 + private static function getDefaultPaymentTemplate(): string {
1346 + return '<h2>Payment received — thank you</h2>
1102 1347
1103 1348 <p>Dear {{client_name}},</p>
1104 1349
1105 -<div class="success-box">
1106 - <p><strong>Payment Confirmation</strong><br>
1107 - Invoice #{{invoice_number}}<br>
1108 - <span class="amount-highlight">{{payment_amount}}</span><br>
1109 - Payment Date: <strong>{{payment_date}}</strong><br>
1110 - Payment Method: <strong>{{payment_method}}</strong></p>
1111 -</div>
1350 +<p>We have received your payment of <strong>{{payment_amount}}</strong> against invoice {{invoice_number}}.</p>
1112 1351
1113 -<p>We have successfully received your payment. Thank you for your prompt payment!</p>
1114 -
1115 1352 <div class="info-box">
1116 - <p><strong>📊 Payment Details:</strong><br>
1117 - • Invoice Number: {{invoice_number}}<br>
1118 - • Amount Paid: {{payment_amount}}<br>
1119 - • Payment Date: {{payment_date}}<br>
1120 - • Payment Method: {{payment_method}}<br>
1121 - • 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>
1122 1359 </div>
1123 1360
1124 -<div class="highlight-box">
1125 - <p><strong>🎉 Status: PAID</strong><br>
1126 - Your payment has been processed and your account is now up to date. We appreciate your business!</p>
1127 -</div>
1361 +<p>Keep this email as your receipt. If you need anything else, reply to this message.</p>
1128 1362
1129 -<p>If you have any questions about this payment or need a receipt, please don\'t hesitate to contact us.</p>
1130 -
1131 1363 <div class="divider"></div>
1132 1364
1133 -<p>Thank you for choosing our services!</p>
1365 +<p>Thank you for your business.</p>
1134 1366
1135 -<p>Best regards,<br>
1136 -<strong>{{company_name}}</strong><br>
1367 +<p>{{company_name}}<br>
1137 1368 {{company_email}}</p>';
1138 1369 }
1139 1370
1140 - private function getDefaultQuoteTemplate(): string {
1141 - return '<h2>📋 Your Quote is Ready</h2>
1371 + private static function getDefaultQuoteTemplate(): string {
1372 + return '<h2>Quote {{quote_number}}</h2>
1142 1373
1143 1374 <p>Dear {{client_name}},</p>
1144 1375
1145 -<div class="highlight-box">
1146 - <p><strong>Quote #{{quote_number}}</strong><br>
1147 - <span class="amount-highlight">{{total_amount}}</span><br>
1148 - Valid Until: <strong>{{expiry_date}}</strong></p>
1149 -</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>
1150 1377
1151 -<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>
1152 1380
1153 1381 <div class="info-box">
1154 - <p><strong>📋 Quote Summary:</strong><br>
1155 - • Quote Number: {{quote_number}}<br>
1156 - • Total Amount: {{total_amount}}<br>
1157 - • Valid Until: {{expiry_date}}<br>
1158 - • Terms: {{payment_terms}}</p>
1382 + <p>Quote number: {{quote_number}}<br>
1383 + Amount: {{total_amount}}<br>
1384 + Valid until: {{expiry_date}}</p>
1159 1385 </div>
1160 1386
1161 -<div class="highlight-box">
1162 - <p><strong>🔗 View Quote Online:</strong><br>
1163 - <a href="{{quote_url}}" style="color: #3b82f6; text-decoration: underline;">{{quote_url}}</a></p>
1164 - <p><strong>Shortcode:</strong> <code>[easy_quote_url number="{{quote_number}}" text="View Quote"]</code></p>
1165 -</div>
1387 +<p>If you would like to discuss any part of it, just reply to this email.</p>
1166 1388
1167 -<div class="warning-box">
1168 - <p><strong>⏰ Time Sensitive:</strong> This quote is valid until {{expiry_date}}. Please review and respond within this timeframe.</p>
1169 -</div>
1170 -
1171 -<p>If you have any questions or would like to discuss any aspects of this quote, please contact us.</p>
1172 -
1173 1389 <div class="divider"></div>
1174 1390
1175 -<p>We look forward to working with you!</p>
1391 +<p>We look forward to working with you.</p>
1176 1392
1177 -<p>Best regards,<br>
1178 -<strong>{{company_name}}</strong><br>
1393 +<p>{{company_name}}<br>
1179 1394 {{company_email}}</p>';
1180 1395 }
1181 1396
1182 - private function getDefaultQuoteAcceptedTemplate(): string {
1183 - return '<h2>🎉 Quote Accepted - Project Confirmed!</h2>
1397 + private static function getDefaultQuoteAcceptedTemplate(): string {
1398 + return '<h2>Quote {{quote_number}} accepted</h2>
1184 1399
1185 1400 <p>Dear {{client_name}},</p>
1186 1401
1187 -<div class="success-box">
1188 - <p><strong>Quote #{{quote_number}} - ACCEPTED</strong><br>
1189 - <span class="amount-highlight">{{total_amount}}</span><br>
1190 - Acceptance Date: <strong>{{acceptance_date}}</strong></p>
1191 -</div>
1402 +<p>Thank you for accepting quote {{quote_number}} for <strong>{{total_amount}}</strong> on {{acceptance_date}}.</p>
1192 1403
1193 -<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>
1194 1405
1195 -<div class="info-box">
1196 - <p><strong>🚀 Next Steps:</strong><br>
1197 - • We will create an invoice for the accepted quote<br>
1198 - • You will receive payment instructions<br>
1199 - • Project work will begin as scheduled</p>
1200 -</div>
1201 -
1202 -<div class="highlight-box">
1203 - <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>
1204 -</div>
1205 -
1206 1406 <div class="divider"></div>
1207 1407
1208 -<p>Thank you for choosing our services!</p>
1408 +<p>Thank you for choosing us.</p>
1209 1409
1210 -<p>Best regards,<br>
1211 -<strong>{{company_name}}</strong><br>
1410 +<p>{{company_name}}<br>
1212 1411 {{company_email}}</p>';
1213 1412 }
1214 1413
1215 - private function getDefaultQuoteDeclinedTemplate(): string {
1216 - return '<h2>📝 Quote Response Received</h2>
1414 + private static function getDefaultQuoteDeclinedTemplate(): string {
1415 + return '<h2>Quote {{quote_number}}</h2>
1217 1416
1218 1417 <p>Dear {{client_name}},</p>
1219 1418
1220 -<div class="warning-box">
1221 - <p><strong>Quote #{{quote_number}} - DECLINED</strong><br>
1222 - Response Date: <strong>{{response_date}}</strong></p>
1223 -</div>
1419 +<p>Thank you for letting us know that quote {{quote_number}} is not going ahead ({{response_date}}).</p>
1224 1420
1225 -<p>We have received your response regarding our quote. We understand that this quote may not have met your current needs.</p>
1226 -
1227 1421 <div class="info-box">
1228 - <p><strong>📋 Feedback:</strong><br>
1229 - • Reason: {{decline_reason}}<br>
1230 - • Response Date: {{response_date}}</p>
1422 + <p>Reason given: {{decline_reason}}</p>
1231 1423 </div>
1232 1424
1233 -<div class="highlight-box">
1234 - <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>
1235 -</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>
1236 1426
1237 1427 <div class="divider"></div>
1238 1428
1239 -<p>Thank you for considering our services!</p>
1240 -
1241 -<p>Best regards,<br>
1242 -<strong>{{company_name}}</strong><br>
1429 +<p>{{company_name}}<br>
1243 1430 {{company_email}}</p>';
1244 1431 }
1245 1432
1246 1433 /**
@@ -1374,8 +1561,10 @@
1374 1561 return ['success' => false, 'message' => __('Payment email template not found', 'easy-invoice')];
1375 1562 }
1376 1563
1377 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);
1378 1567
1379 1568 // Check if email is enabled
1380 1569 if (!$template['enabled']) {
1381 1570 return ['success' => false, 'message' => __('Payment received email is disabled', 'easy-invoice')];
@@ -1391,8 +1580,17 @@
1391 1580 $email_data['message'],
1392 1581 $email_data['headers']
1393 1582 );
1394 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 +
1395 1593 if ($sent) {
1396 1594 // Log success
1397 1595 do_action('easy_invoice_payment_email_sent', $invoice, $client_email, $payment_data);
1398 1596
@@ -1439,13 +1637,16 @@
1439 1637 $payment_method_label = $this->getPaymentMethodLabel($payment_method);
1440 1638
1441 1639 // Format amount
1442 1640 $formatter = new \EasyInvoice\Helpers\InvoiceFormatter($invoice);
1443 - $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());
1444 1643
1445 1644 // Prepare email subject
1645 + $pending = !empty($payment_data['pending']);
1446 1646 $subject = sprintf(
1447 - __('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'),
1448 1649 $invoice->getNumber()
1449 1650 );
1450 1651
1451 1652 // Prepare email message
@@ -1479,8 +1680,34 @@
1479 1680 }
1480 1681 }
1481 1682
1482 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 + /**
1483 1710 * Send payment confirmation email to customer
1484 1711 *
1485 1712 * @param Invoice $invoice The invoice
1486 1713 * @param array $payment_data Payment data
@@ -1511,9 +1738,10 @@
1511 1738
1512 1739 // Prepare email subject
1513 1740 $site_name = get_bloginfo('name');
1514 1741 $subject = sprintf(
1515 - __('[%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'),
1516 1744 $site_name,
1517 1745 $invoice->getNumber()
1518 1746 );
1519 1747
@@ -1577,9 +1805,10 @@
1577 1805
1578 1806 // Prepare email subject
1579 1807 $site_name = get_bloginfo('name');
1580 1808 $subject = sprintf(
1581 - __('[%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'),
1582 1811 $site_name,
1583 1812 $invoice->getNumber()
1584 1813 );
1585 1814
@@ -1633,25 +1862,42 @@
1633 1862 $customer_name = $invoice->getCustomerName();
1634 1863 $customer_email = $invoice->getCustomerEmail();
1635 1864 $invoice_id = $invoice->getId();
1636 1865
1866 + $pending = !empty($payment_data['pending']);
1637 1867 $message = sprintf(
1638 - __('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'),
1639 1870 $payment_method_label,
1640 1871 $invoice_number
1641 1872 );
1642 1873 $message .= "\n\n";
1874 + /* translators: . */
1643 1875 $message .= __('Invoice Details:', 'easy-invoice');
1644 1876 $message .= "\n";
1645 - $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);
1646 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. */
1647 1885 $message .= sprintf(__('- Customer: %s', 'easy-invoice'), $customer_name);
1648 1886 $message .= "\n";
1887 + /* translators: %s: customer email address. */
1649 1888 $message .= sprintf(__('- Email: %s', 'easy-invoice'), $customer_email);
1650 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 +
1651 1896 // Add transaction ID if available
1652 1897 if (!empty($payment_data['transaction_id'])) {
1653 1898 $message .= "\n";
1899 + /* translators: %s: transaction id. */
1654 1900 $message .= sprintf(__('- Transaction ID: %s', 'easy-invoice'), $payment_data['transaction_id']);
1655 1901 }
1656 1902
1657 1903 $message .= "\n\n";
@@ -1676,12 +1922,15 @@
1676 1922 $invoice_number = $invoice->getNumber();
1677 1923 $site_name = get_bloginfo('name');
1678 1924 $company_name = get_option('easy_invoice_company_name', $site_name);
1679 1925
1926 + /* translators: %s: customer name. */
1927 + /* translators: %s: customer name. */
1680 1928 $message = sprintf(__('Dear %s,', 'easy-invoice'), $customer_name);
1681 1929 $message .= "\n\n";
1682 1930 $message .= sprintf(
1683 - __('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'),
1684 1933 $formatted_amount,
1685 1934 $invoice_number
1686 1935 );
1687 1936 $message .= "\n\n";
@@ -1706,11 +1955,14 @@
1706 1955 $invoice_number = $invoice->getNumber();
1707 1956 $site_name = get_bloginfo('name');
1708 1957 $company_name = get_option('easy_invoice_company_name', $site_name);
1709 1958
1959 + /* translators: %s: customer name. */
1960 + /* translators: %s: customer name. */
1710 1961 $message = sprintf(__('Dear %s,', 'easy-invoice'), $customer_name);
1711 1962 $message .= "\n\n";
1712 1963 $message .= sprintf(
1964 + /* translators: %s: invoice number. */
1713 1965 __('We regret to inform you that your payment for Invoice #%s has been rejected.', 'easy-invoice'),
1714 1966 $invoice_number
1715 1967 );
1716 1968
@@ -1752,9 +2004,10 @@
1752 2004 }
1753 2005
1754 2006 // Prepare email subject
1755 2007 $subject = sprintf(
1756 - __('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'),
1757 2010 $quote->getNumber(),
1758 2011 $action === 'accepted' ? __('accepted', 'easy-invoice') : __('declined', 'easy-invoice')
1759 2012 );
1760 2013
@@ -1807,26 +2060,33 @@
1807 2060
1808 2061 $action_label = $action === 'accepted' ? __('accepted', 'easy-invoice') : __('declined', 'easy-invoice');
1809 2062 $date_label = $action === 'accepted' ? __('Accepted Date', 'easy-invoice') : __('Declined Date', 'easy-invoice');
1810 2063
2064 + /* translators: . */
1811 2065 $message = __('Hello,', 'easy-invoice');
1812 2066 $message .= "\n\n";
1813 2067 $message .= sprintf(
1814 - __('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'),
1815 2070 $quote_number,
1816 2071 $customer_name,
1817 2072 $action_label
1818 2073 );
1819 2074 $message .= "\n\n";
2075 + /* translators: . */
1820 2076 $message .= __('Quote Details:', 'easy-invoice');
1821 2077 $message .= "\n";
2078 + /* translators: %s: quote number. */
1822 2079 $message .= sprintf(__('- Quote Number: %s', 'easy-invoice'), $quote_number);
1823 2080 $message .= "\n";
2081 + /* translators: %s: customer name. */
1824 2082 $message .= sprintf(__('- Client: %s', 'easy-invoice'), $customer_name);
1825 2083 $message .= "\n";
2084 + /* translators: %s: amount. */
1826 2085 $message .= sprintf(__('- Total Amount: %s', 'easy-invoice'), $formatted_amount);
1827 2086 $message .= "\n";
1828 - $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')));
1829 2089 $message .= "\n\n";
1830 2090 $message .= __('You can view the quote at:', 'easy-invoice');
1831 2091 $message .= "\n";
1832 2092 $message .= get_permalink($quote->getId());
@@ -1857,9 +2117,9 @@
1857 2117
1858 2118 // Send customer confirmation email using proper template system
1859 2119 // Check if payment email is enabled first
1860 2120 if (isset($this->templates['invoice_paid']) && $this->templates['invoice_paid']['enabled']) {
1861 - $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]));
1862 2122 }
1863 2123 }
1864 2124
1865 2125 /**
@@ -1870,9 +2130,15 @@
1870 2130 */
1871 2131 private function getPaymentMethodLabel(string $method): string {
1872 2132 $labels = [
1873 2133 'bank' => __('Bank Transfer', 'easy-invoice'),
2134 + 'bank_transfer' => __('Bank Transfer', 'easy-invoice'),
2135 + 'cash' => __('Cash', 'easy-invoice'),
2136 + 'check' => __('Cheque', 'easy-invoice'),
1874 2137 'cheque' => __('Cheque', 'easy-invoice'),
2138 + 'paystack' => __('Paystack', 'easy-invoice'),
2139 + 'moneris' => __('Moneris', 'easy-invoice'),
2140 + 'other' => __('Other', 'easy-invoice'),
1875 2141 'paypal' => __('PayPal', 'easy-invoice'),
1876 2142 'stripe' => __('Stripe', 'easy-invoice'),
1877 2143 'square' => __('Square', 'easy-invoice'),
1878 2144 'mollie' => __('Mollie', 'easy-invoice'),