PluginProbe
Easy Invoice – Invoice Generator, PDF Quotes & Payments / 2.4.3
Easy Invoice – Invoice Generator, PDF Quotes & Payments v2.4.3
2.4.3 2.4.4 2.4.2 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 All 60 releases
← All changes | includes/Services/EmailManager.php +1054 -319 2.1.0 → 2.4.3 View file →
@@ -79,17 +79,14 @@
79 79 /**
80 80 * Initialize hooks
81 81 */
82 82 private function initHooks(): void {
83 - // Register AJAX handlers
84 - add_action('wp_ajax_easy_invoice_send_invoice_email', [$this, 'handleSendInvoiceEmail']);
85 - add_action('wp_ajax_nopriv_easy_invoice_send_invoice_email', [$this, 'handleSendInvoiceEmail']);
86 - add_action('wp_ajax_easy_invoice_send_quote_email', [$this, 'handleSendQuoteEmail']);
87 - add_action('wp_ajax_nopriv_easy_invoice_send_quote_email', [$this, 'handleSendQuoteEmail']);
88 -
83 + // Invoice/quote send-email AJAX is handled exclusively by EasyInvoice\Admin\EasyInvoiceAjax
84 + // (published-document checks, single handler) to avoid duplicate nopriv callbacks.
85 +
89 86 // Add email settings to admin
90 87 add_action('admin_init', [$this, 'registerEmailSettings']);
91 - add_filter('easy_invoice_settings_sections', [$this, 'addEmailSettingsSection']);
88 + add_action('admin_init', [__CLASS__, 'refreshStockTemplates']);
92 89
93 90 // Refresh settings when they're updated
94 91 add_action('update_option_easy_invoice_email_from_name', [$this, 'refreshSettings']);
95 92 add_action('update_option_easy_invoice_email_from_address', [$this, 'refreshSettings']);
@@ -107,8 +104,19 @@
107 104
108 105 // Add email logs
109 106 add_action('easy_invoice_email_sent', [$this, 'logEmailSent'], 10, 3);
110 107 add_action('easy_invoice_email_failed', [$this, 'logEmailFailed'], 10, 3);
108 +
109 + // Listen for payment completion to send admin notifications
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, 3);
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,14 +423,17 @@
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);
435 + $message = do_shortcode($message); // Render shortcodes like [easy_invoice_url ...]
323 436
324 437 // Add HTML wrapper if enabled
325 438 if ($this->settings['enable_html'] === 'yes') {
326 439 $message = $this->wrapInHtmlTemplate($message);
@@ -326,9 +439,9 @@
326 439 $message = $this->wrapInHtmlTemplate($message);
327 440 }
328 441
329 442 // Prepare headers
330 - $headers = $this->prepareEmailHeaders();
443 + $headers = $this->prepareEmailHeaders('invoice', $invoice);
331 444
332 445 // Add BCC to admin if enabled
333 446 if ($this->settings['bcc_admin'] === 'yes' && !empty($this->settings['admin_email'])) {
334 447 $headers[] = 'Bcc: ' . $this->settings['admin_email'];
@@ -363,9 +476,9 @@
363 476 $message = $this->wrapInHtmlTemplate($message);
364 477 }
365 478
366 479 // Prepare headers
367 - $headers = $this->prepareEmailHeaders();
480 + $headers = $this->prepareEmailHeaders('quote', $quote);
368 481
369 482 // Add BCC to admin if enabled
370 483 if ($this->settings['bcc_admin'] === 'yes' && !empty($this->settings['admin_email'])) {
371 484 $headers[] = 'Bcc: ' . $this->settings['admin_email'];
@@ -400,9 +513,9 @@
400 513 $message = $this->wrapInHtmlTemplate($message);
401 514 }
402 515
403 516 // Prepare headers
404 - $headers = $this->prepareEmailHeaders();
517 + $headers = $this->prepareEmailHeaders('receipt', $invoice);
405 518
406 519 // Add BCC to admin if enabled
407 520 if ($this->settings['bcc_admin'] === 'yes' && !empty($this->settings['admin_email'])) {
408 521 $headers[] = 'Bcc: ' . $this->settings['admin_email'];
@@ -425,8 +538,38 @@
425 538 */
426 539 private function getInvoiceReplacements(Invoice $invoice, array $additional_data = []): array {
427 540 $currency_symbol = get_option('easy_invoice_currency_symbol', '$');
428 541
542 + // Secure link support
543 + $invoice_url = get_permalink($invoice->getId());
544 + $secure_links_enabled = get_option('easy_invoice_pro_enable_secure_links', 'no') === 'yes';
545 + if ($secure_links_enabled && class_exists('\EasyInvoicePro\Addons\SecureLinks\Controllers\PermalinkController')) {
546 + $secure_url = \EasyInvoicePro\Addons\SecureLinks\Controllers\PermalinkController::getInvoiceSecureLinkUrl($invoice->getId());
547 + if ($secure_url) {
548 + $invoice_url = $secure_url;
549 + }
550 + }
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 +
565 + // Get client data for additional fields
566 + $client = null;
567 + if ($invoice->getClientId()) {
568 + $client_repository = new \EasyInvoice\Repositories\ClientRepository();
569 + $client = $client_repository->find($invoice->getClientId());
570 + }
571 +
429 572 return array_merge([
430 573 '{{invoice_number}}' => $invoice->getNumber(),
431 574 '{{invoice_title}}' => $invoice->getTitle(),
432 575 '{{client_name}}' => $invoice->getCustomerName(),
@@ -431,26 +574,48 @@
431 574 '{{invoice_title}}' => $invoice->getTitle(),
432 575 '{{client_name}}' => $invoice->getCustomerName(),
433 576 '{{client_email}}' => $invoice->getCustomerEmail(),
434 577 '{{client_address}}' => $invoice->getCustomerAddress(),
435 - '{{company_name}}' => get_bloginfo('name'),
578 + '{{client_first_name}}' => $client ? $client->getFirstName() : '',
579 + '{{client_last_name}}' => $client ? $client->getLastName() : '',
580 + '{{company_name}}' => get_option('easy_invoice_company_name') ?: get_bloginfo('name'),
436 581 '{{company_email}}' => $this->settings['from_email'],
437 582 '{{company_phone}}' => get_option('easy_invoice_company_phone', ''),
438 583 '{{company_address}}' => get_option('easy_invoice_company_address', ''),
439 584 '{{company_website}}' => get_option('easy_invoice_company_website', ''),
440 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)),
441 587 '{{subtotal}}' => (new \EasyInvoice\Helpers\InvoiceFormatter($invoice))->format($invoice->getSubtotal()),
442 588 '{{tax_amount}}' => (new \EasyInvoice\Helpers\InvoiceFormatter($invoice))->format($invoice->getTaxAmount()),
443 589 '{{discount_amount}}' => (new \EasyInvoice\Helpers\InvoiceFormatter($invoice))->format($invoice->getDiscountAmount()),
444 - '{{due_date}}' => date('F j, Y', strtotime($invoice->getDueDate())),
445 - '{{issue_date}}' => date('F j, Y', strtotime($invoice->getIssueDate())),
446 - '{{invoice_url}}' => get_permalink($invoice->getId()),
590 + '{{due_date}}' => gmdate('F j, Y', strtotime($invoice->getDueDate())),
591 + '{{issue_date}}' => gmdate('F j, Y', strtotime($invoice->getIssueDate())),
592 + '{{invoice_url}}' => $invoice_url, // Use correct (possibly secure) link
447 593 '{{payment_url}}' => add_query_arg('payment', '1', get_permalink($invoice->getId())),
448 594 '{{site_url}}' => get_site_url(),
449 595 '{{admin_url}}' => admin_url(),
450 596 '{{payment_terms}}' => get_option('easy_invoice_payment_terms', __('Due on receipt', 'easy-invoice')),
451 - ], $additional_data);
597 + ], self::placeholderKeysOnly($additional_data));
452 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 + }
453 618
454 619 /**
455 620 * Get quote replacements
456 621 *
@@ -460,8 +625,38 @@
460 625 */
461 626 private function getQuoteReplacements(Quote $quote, array $additional_data = []): array {
462 627 $currency_symbol = get_option('easy_invoice_currency_symbol', '$');
463 628
629 + $quote_url = get_permalink($quote->getId());
630 + $secure_links_enabled = get_option('easy_invoice_pro_enable_secure_links', 'no') === 'yes';
631 + if ($secure_links_enabled && class_exists('\EasyInvoicePro\Addons\SecureLinks\Controllers\PermalinkController')) {
632 + $secure_url = \EasyInvoicePro\Addons\SecureLinks\Controllers\PermalinkController::getQuoteSecureLinkUrl($quote->getId());
633 + if ($secure_url) {
634 + $quote_url = $secure_url;
635 + }
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 + }
651 +
652 + // Get client data for additional fields
653 + $client = null;
654 + if ($quote->getClientId()) {
655 + $client_repository = new \EasyInvoice\Repositories\ClientRepository();
656 + $client = $client_repository->find($quote->getClientId());
657 + }
658 +
464 659 return array_merge([
465 660 '{{quote_number}}' => $quote->getNumber(),
466 661 '{{quote_title}}' => $quote->getTitle(),
467 662 '{{client_name}}' => $quote->getCustomerName(),
@@ -466,9 +661,11 @@
466 661 '{{quote_title}}' => $quote->getTitle(),
467 662 '{{client_name}}' => $quote->getCustomerName(),
468 663 '{{client_email}}' => $quote->getCustomerEmail(),
469 664 '{{client_address}}' => $quote->getCustomerAddress(),
470 - '{{company_name}}' => get_bloginfo('name'),
665 + '{{client_first_name}}' => $client ? $client->getFirstName() : '',
666 + '{{client_last_name}}' => $client ? $client->getLastName() : '',
667 + '{{company_name}}' => get_option('easy_invoice_company_name') ?: get_bloginfo('name'),
471 668 '{{company_email}}' => $this->settings['from_email'],
472 669 '{{company_phone}}' => get_option('easy_invoice_company_phone', ''),
473 670 '{{company_address}}' => get_option('easy_invoice_company_address', ''),
474 671 '{{company_website}}' => get_option('easy_invoice_company_website', ''),
@@ -475,15 +672,15 @@
475 672 '{{total_amount}}' => (new \EasyInvoice\Helpers\QuoteFormatter($quote))->format($quote->getTotal()),
476 673 '{{subtotal}}' => (new \EasyInvoice\Helpers\QuoteFormatter($quote))->format($quote->getSubtotal()),
477 674 '{{tax_amount}}' => (new \EasyInvoice\Helpers\QuoteFormatter($quote))->format($quote->getTaxAmount()),
478 675 '{{discount_amount}}' => (new \EasyInvoice\Helpers\QuoteFormatter($quote))->format($quote->getDiscountAmount()),
479 - '{{expiry_date}}' => date('F j, Y', strtotime($quote->getExpiryDate())),
480 - '{{issue_date}}' => date('F j, Y', strtotime($quote->getIssueDate())),
481 - '{{quote_url}}' => get_permalink($quote->getId()),
676 + '{{expiry_date}}' => gmdate('F j, Y', strtotime($quote->getExpiryDate())),
677 + '{{issue_date}}' => gmdate('F j, Y', strtotime($quote->getIssueDate())),
678 + '{{quote_url}}' => $quote_url,
482 679 '{{site_url}}' => get_site_url(),
483 680 '{{admin_url}}' => admin_url(),
484 681 '{{payment_terms}}' => get_option('easy_invoice_payment_terms', __('Due on receipt', 'easy-invoice')),
485 - ], $additional_data);
682 + ], self::placeholderKeysOnly($additional_data));
486 683 }
487 684
488 685 /**
489 686 * Get payment replacements
@@ -495,12 +692,16 @@
495 692 private function getPaymentReplacements(Invoice $invoice, array $payment_data = []): array {
496 693 $replacements = $this->getInvoiceReplacements($invoice, $payment_data);
497 694
498 695 // Add payment-specific replacements
499 - $replacements['{{payment_amount}}'] = isset($payment_data['amount']) ? $this->formatCurrency($payment_data['amount']) : $this->formatCurrency($invoice->getTotal());
500 - $replacements['{{payment_date}}'] = isset($payment_data['date']) ? $payment_data['date'] : current_time('Y-m-d');
501 - $replacements['{{payment_method}}'] = isset($payment_data['method']) ? $payment_data['method'] : __('Online Payment', 'easy-invoice');
502 - $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');
503 704 $replacements['{{acceptance_date}}'] = isset($payment_data['acceptance_date']) ? $payment_data['acceptance_date'] : current_time('Y-m-d');
504 705 $replacements['{{response_date}}'] = isset($payment_data['response_date']) ? $payment_data['response_date'] : current_time('Y-m-d');
505 706 $replacements['{{decline_reason}}'] = isset($payment_data['decline_reason']) ? $payment_data['decline_reason'] : __('No specific reason provided', 'easy-invoice');
506 707
@@ -522,9 +723,33 @@
522 723 * Prepare email headers
523 724 *
524 725 * @return array Headers
525 726 */
526 - 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 {
527 752 $headers = [
528 753 'Content-Type: text/html; charset=UTF-8',
529 754 'From: ' . $this->settings['from_name'] . ' <' . $this->settings['from_email'] . '>',
530 755 ];
@@ -533,10 +758,25 @@
533 758 if (!empty($this->settings['reply_to_email'])) {
534 759 $reply_to_name = !empty($this->settings['reply_to_name']) ? $this->settings['reply_to_name'] : $this->settings['from_name'];
535 760 $headers[] = 'Reply-To: ' . $reply_to_name . ' <' . $this->settings['reply_to_email'] . '>';
536 761 }
537 -
538 - 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);
539 779 }
540 780
541 781 /**
542 782 * Wrap message in HTML template
@@ -543,8 +783,69 @@
543 783 *
544 784 * @param string $message The message
545 785 * @return string HTML wrapped message
546 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 +
547 848 private function wrapInHtmlTemplate(string $message): string {
548 849 $logo_html = '';
549 850 if (!empty($this->settings['email_logo'])) {
550 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>';
@@ -553,8 +854,34 @@
553 854 $footer_html = '';
554 855 if (!empty($this->settings['footer_text'])) {
555 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>';
556 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 + }
557 884
558 885 return '
559 886 <!DOCTYPE html>
560 887 <html>
@@ -780,78 +1107,8 @@
780 1107 </html>';
781 1108 }
782 1109
783 1110 /**
784 - * Handle AJAX send invoice email
785 - */
786 - public function handleSendInvoiceEmail(): void {
787 - // Verify nonce
788 - if (!wp_verify_nonce($_POST['nonce'] ?? '', 'easy_invoice_send_invoice_email')) {
789 - wp_send_json_error(__('Security check failed', 'easy-invoice'));
790 - }
791 -
792 - // Get invoice ID
793 - $invoice_id = isset($_POST['invoice_id']) ? intval($_POST['invoice_id']) : 0;
794 - if (!$invoice_id) {
795 - wp_send_json_error(__('Invalid invoice ID', 'easy-invoice'));
796 - }
797 -
798 - // Get invoice
799 - $repository = \EasyInvoice\Providers\InvoiceServiceProvider::getInvoiceRepository();
800 - $invoice = $repository->find($invoice_id);
801 -
802 - if (!$invoice) {
803 - wp_send_json_error(__('Invoice not found', 'easy-invoice'));
804 - }
805 -
806 - // Send email
807 - $result = $this->sendInvoiceEmail($invoice, 'new');
808 -
809 - if ($result['success']) {
810 - wp_send_json_success($result['message']);
811 - } else {
812 - wp_send_json_error($result['message']);
813 - }
814 - }
815 -
816 - /**
817 - * Handle AJAX send quote email
818 - */
819 - public function handleSendQuoteEmail(): void {
820 - // Verify nonce
821 - if (!wp_verify_nonce($_POST['nonce'] ?? '', 'easy_invoice_send_quote_email')) {
822 - wp_send_json_error(__('Security check failed', 'easy-invoice'));
823 - }
824 -
825 - // Get quote ID
826 - $quote_id = isset($_POST['quote_id']) ? intval($_POST['quote_id']) : 0;
827 - if (!$quote_id) {
828 - wp_send_json_error(__('Invalid quote ID', 'easy-invoice'));
829 - }
830 -
831 - // Get quote
832 - $repository = \EasyInvoice\Providers\QuoteServiceProvider::getQuoteRepository();
833 - $quote = $repository->find($quote_id);
834 -
835 - if (!$quote) {
836 - wp_send_json_error(__('Quote not found', 'easy-invoice'));
837 - }
838 -
839 - // Send email
840 - $result = $this->sendQuoteEmail($quote, 'new');
841 -
842 - if ($result['success']) {
843 - // Log the quote email sent
844 - $quote_log_service = new \EasyInvoice\Services\QuoteLogService();
845 - $quote_log_service->logSent($quote_id, $quote->getCustomerEmail());
846 -
847 - wp_send_json_success($result['message']);
848 - } else {
849 - wp_send_json_error($result['message']);
850 - }
851 - }
852 -
853 - /**
854 1111 * Register email settings
855 1112 */
856 1113 public function registerEmailSettings(): void {
857 1114 // Email settings section
@@ -862,17 +1119,20 @@
862 1119 'easy_invoice_settings'
863 1120 );
864 1121
865 1122 // Register settings
866 - register_setting('easy_invoice_settings', 'easy_invoice_email_from_name');
867 - register_setting('easy_invoice_settings', 'easy_invoice_email_from_address');
868 - register_setting('easy_invoice_settings', 'easy_invoice_email_reply_to');
869 - register_setting('easy_invoice_settings', 'easy_invoice_email_reply_to_name');
870 - register_setting('easy_invoice_settings', 'easy_invoice_enable_email_styling');
871 - register_setting('easy_invoice_settings', 'easy_invoice_email_logo');
872 - register_setting('easy_invoice_settings', 'easy_invoice_email_footer_text');
873 - register_setting('easy_invoice_settings', 'easy_invoice_bcc_admin');
874 - 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']);
875 1135
876 1136 // Add settings fields
877 1137 add_settings_field(
878 1138 'easy_invoice_email_from_name',
@@ -919,73 +1179,14 @@
919 1179 ['label_for' => 'easy_invoice_bcc_admin']
920 1180 );
921 1181 }
922 1182
923 - /**
924 - * Add email settings section
925 - *
926 - * @param array $sections Settings sections
927 - * @return array Modified sections
928 - */
929 - public function addEmailSettingsSection(array $sections): array {
930 - $sections['email'] = [
931 - 'title' => __('Email Settings', 'easy-invoice'),
932 - 'description' => __('Configure email sending options and templates', 'easy-invoice'),
933 - 'icon' => 'fas fa-envelope',
934 - 'fields' => [
935 - 'easy_invoice_email_from_name' => [
936 - 'label' => __('From Name', 'easy-invoice'),
937 - 'type' => 'text',
938 - 'default' => get_bloginfo('name'),
939 - 'col_span' => 'sm:col-span-3'
940 - ],
941 - 'easy_invoice_email_from_address' => [
942 - 'label' => __('From Email Address', 'easy-invoice'),
943 - 'type' => 'email',
944 - 'default' => get_bloginfo('admin_email'),
945 - 'col_span' => 'sm:col-span-3'
946 - ],
947 - 'easy_invoice_email_reply_to' => [
948 - 'label' => __('Reply-To Email', 'easy-invoice'),
949 - 'type' => 'email',
950 - 'default' => '',
951 - 'col_span' => 'sm:col-span-3'
952 - ],
953 - 'easy_invoice_enable_email_styling' => [
954 - 'label' => __('Enable HTML Emails', 'easy-invoice'),
955 - 'type' => 'checkbox',
956 - 'default' => 'yes',
957 - 'col_span' => 'sm:col-span-3'
958 - ],
959 - 'easy_invoice_bcc_admin' => [
960 - 'label' => __('BCC Admin on All Emails', 'easy-invoice'),
961 - 'type' => 'checkbox',
962 - 'default' => 'no',
963 - 'col_span' => 'sm:col-span-3'
964 - ],
965 - 'easy_invoice_email_logo' => [
966 - 'label' => __('Email Logo URL', 'easy-invoice'),
967 - 'type' => 'url',
968 - 'default' => '',
969 - 'col_span' => 'sm:col-span-6'
970 - ],
971 - 'easy_invoice_email_footer_text' => [
972 - 'label' => __('Email Footer Text', 'easy-invoice'),
973 - 'type' => 'textarea',
974 - 'default' => '',
975 - 'col_span' => 'sm:col-span-6'
976 - ],
977 - ]
978 - ];
979 -
980 - return $sections;
981 - }
982 1183
983 1184 /**
984 1185 * Email settings section callback
985 1186 */
986 1187 public function emailSettingsSectionCallback(): void {
987 - 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>';
988 1189 }
989 1190
990 1191 /**
991 1192 * Text field callback
@@ -1017,9 +1218,9 @@
1017 1218 public function checkboxFieldCallback(array $args): void {
1018 1219 $field_id = $args['label_for'];
1019 1220 $value = get_option($field_id, '');
1020 1221 echo '<input type="checkbox" id="' . esc_attr($field_id) . '" name="' . esc_attr($field_id) . '" value="yes"' . checked($value, 'yes', false) . '>';
1021 - echo '<span class="description">' . __('Enable this option', 'easy-invoice') . '</span>';
1222 + echo '<span class="description">' . esc_html__('Enable this option', 'easy-invoice') . '</span>';
1022 1223 }
1023 1224
1024 1225 /**
1025 1226 * Log email sent
@@ -1047,232 +1248,186 @@
1047 1248 * Get default invoice template
1048 1249 *
1049 1250 * @return string Template
1050 1251 */
1051 - private function getDefaultInvoiceTemplate(): string {
1052 - 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 + }
1053 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 +
1054 1302 <p>Dear {{client_name}},</p>
1055 1303
1056 -<div class="highlight-box">
1057 - <p><strong>Invoice #{{invoice_number}}</strong><br>
1058 - <span class="amount-highlight">{{total_amount}}</span><br>
1059 - Due Date: <strong>{{due_date}}</strong></p>
1060 -</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>
1061 1305
1062 -<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>
1063 1308
1064 1309 <div class="info-box">
1065 - <p><strong>📋 Payment Details:</strong><br>
1066 - • Invoice Number: {{invoice_number}}<br>
1067 - • Total Amount: {{total_amount}}<br>
1068 - • Due Date: {{due_date}}<br>
1069 - • Payment Terms: {{payment_terms}}</p>
1310 + <p>Invoice number: {{invoice_number}}<br>
1311 + Amount due: {{amount_due}}<br>
1312 + Due date: {{due_date}}</p>
1070 1313 </div>
1071 1314
1072 -<div class="highlight-box">
1073 - <p><strong>🔗 View Invoice Online:</strong><br>
1074 - <a href="{{invoice_url}}" style="color: #3b82f6; text-decoration: underline;">{{invoice_url}}</a></p>
1075 - <p><strong>Shortcode:</strong> <code>[easy_invoice_url number="{{invoice_number}}" text="View Invoice"]</code></p>
1076 -</div>
1315 +<p>If you have any questions about this invoice, just reply to this email.</p>
1077 1316
1078 -<div class="warning-box">
1079 - <p><strong>⚠️ Important:</strong> Please ensure payment is received by the due date to avoid any late fees or service interruptions.</p>
1080 -</div>
1081 -
1082 -<p>If you have any questions about this invoice, please do not hesitate to contact us.</p>
1083 -
1084 1317 <div class="divider"></div>
1085 1318
1086 -<p>Thank you for your business!</p>
1319 +<p>Thank you for your business.</p>
1087 1320
1088 -<p>Best regards,<br>
1089 -<strong>{{company_name}}</strong><br>
1321 +<p>{{company_name}}<br>
1090 1322 {{company_email}}</p>';
1091 1323 }
1092 1324
1093 - private function getDefaultReminderTemplate(): string {
1094 - return '<h2>⏰ Payment Reminder</h2>
1325 + private static function getDefaultReminderTemplate(): string {
1326 + return '<h2>Payment reminder — invoice {{invoice_number}}</h2>
1095 1327
1096 1328 <p>Dear {{client_name}},</p>
1097 1329
1098 -<div class="warning-box">
1099 - <p><strong>Invoice #{{invoice_number}}</strong><br>
1100 - <span class="amount-highlight">{{total_amount}}</span><br>
1101 - Due Date: <strong>{{due_date}}</strong></p>
1102 -</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>
1103 1331
1104 -<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>
1105 1334
1106 -<div class="info-box">
1107 - <p><strong>💳 Payment Options:</strong><br>
1108 - • Online payment through our secure portal<br>
1109 - • Bank transfer to the details provided<br>
1110 - • Check or money order</p>
1111 -</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>
1112 1336
1113 -<div class="highlight-box">
1114 - <p><strong>🔗 View Invoice Online:</strong><br>
1115 - <a href="{{invoice_url}}" style="color: #3b82f6; text-decoration: underline;">{{invoice_url}}</a></p>
1116 - <p><strong>Shortcode:</strong> <code>[easy_invoice_url number="{{invoice_number}}" text="View Invoice"]</code></p>
1117 -</div>
1337 +<div class="divider"></div>
1118 1338
1119 -<div class="highlight-box">
1120 - <p><strong>📞 Need Help?</strong> If you have any questions or need to discuss payment arrangements, please contact us immediately.</p>
1121 -</div>
1339 +<p>Thank you.</p>
1122 1340
1123 -<p>Thank you for your prompt attention to this matter.</p>
1124 -
1125 -<div class="divider"></div>
1126 -
1127 -<p>Best regards,<br>
1128 -<strong>{{company_name}}</strong><br>
1341 +<p>{{company_name}}<br>
1129 1342 {{company_email}}</p>';
1130 1343 }
1131 1344
1132 - private function getDefaultPaymentTemplate(): string {
1133 - return '<h2>✅ Payment Received - Thank You!</h2>
1345 + private static function getDefaultPaymentTemplate(): string {
1346 + return '<h2>Payment received — thank you</h2>
1134 1347
1135 1348 <p>Dear {{client_name}},</p>
1136 1349
1137 -<div class="success-box">
1138 - <p><strong>Payment Confirmation</strong><br>
1139 - Invoice #{{invoice_number}}<br>
1140 - <span class="amount-highlight">{{payment_amount}}</span><br>
1141 - Payment Date: <strong>{{payment_date}}</strong><br>
1142 - Payment Method: <strong>{{payment_method}}</strong></p>
1143 -</div>
1350 +<p>We have received your payment of <strong>{{payment_amount}}</strong> against invoice {{invoice_number}}.</p>
1144 1351
1145 -<p>We have successfully received your payment. Thank you for your prompt payment!</p>
1146 -
1147 1352 <div class="info-box">
1148 - <p><strong>📊 Payment Details:</strong><br>
1149 - • Invoice Number: {{invoice_number}}<br>
1150 - • Amount Paid: {{payment_amount}}<br>
1151 - • Payment Date: {{payment_date}}<br>
1152 - • Payment Method: {{payment_method}}<br>
1153 - • 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>
1154 1359 </div>
1155 1360
1156 -<div class="highlight-box">
1157 - <p><strong>🎉 Status: PAID</strong><br>
1158 - Your payment has been processed and your account is now up to date. We appreciate your business!</p>
1159 -</div>
1361 +<p>Keep this email as your receipt. If you need anything else, reply to this message.</p>
1160 1362
1161 -<p>If you have any questions about this payment or need a receipt, please don\'t hesitate to contact us.</p>
1162 -
1163 1363 <div class="divider"></div>
1164 1364
1165 -<p>Thank you for choosing our services!</p>
1365 +<p>Thank you for your business.</p>
1166 1366
1167 -<p>Best regards,<br>
1168 -<strong>{{company_name}}</strong><br>
1367 +<p>{{company_name}}<br>
1169 1368 {{company_email}}</p>';
1170 1369 }
1171 1370
1172 - private function getDefaultQuoteTemplate(): string {
1173 - return '<h2>📋 Your Quote is Ready</h2>
1371 + private static function getDefaultQuoteTemplate(): string {
1372 + return '<h2>Quote {{quote_number}}</h2>
1174 1373
1175 1374 <p>Dear {{client_name}},</p>
1176 1375
1177 -<div class="highlight-box">
1178 - <p><strong>Quote #{{quote_number}}</strong><br>
1179 - <span class="amount-highlight">{{total_amount}}</span><br>
1180 - Valid Until: <strong>{{expiry_date}}</strong></p>
1181 -</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>
1182 1377
1183 -<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>
1184 1380
1185 1381 <div class="info-box">
1186 - <p><strong>📋 Quote Summary:</strong><br>
1187 - • Quote Number: {{quote_number}}<br>
1188 - • Total Amount: {{total_amount}}<br>
1189 - • Valid Until: {{expiry_date}}<br>
1190 - • Terms: {{payment_terms}}</p>
1382 + <p>Quote number: {{quote_number}}<br>
1383 + Amount: {{total_amount}}<br>
1384 + Valid until: {{expiry_date}}</p>
1191 1385 </div>
1192 1386
1193 -<div class="highlight-box">
1194 - <p><strong>🔗 View Quote Online:</strong><br>
1195 - <a href="{{quote_url}}" style="color: #3b82f6; text-decoration: underline;">{{quote_url}}</a></p>
1196 - <p><strong>Shortcode:</strong> <code>[easy_quote_url number="{{quote_number}}" text="View Quote"]</code></p>
1197 -</div>
1387 +<p>If you would like to discuss any part of it, just reply to this email.</p>
1198 1388
1199 -<div class="warning-box">
1200 - <p><strong>⏰ Time Sensitive:</strong> This quote is valid until {{expiry_date}}. Please review and respond within this timeframe.</p>
1201 -</div>
1202 -
1203 -<p>If you have any questions or would like to discuss any aspects of this quote, please contact us.</p>
1204 -
1205 1389 <div class="divider"></div>
1206 1390
1207 -<p>We look forward to working with you!</p>
1391 +<p>We look forward to working with you.</p>
1208 1392
1209 -<p>Best regards,<br>
1210 -<strong>{{company_name}}</strong><br>
1393 +<p>{{company_name}}<br>
1211 1394 {{company_email}}</p>';
1212 1395 }
1213 1396
1214 - private function getDefaultQuoteAcceptedTemplate(): string {
1215 - return '<h2>🎉 Quote Accepted - Project Confirmed!</h2>
1397 + private static function getDefaultQuoteAcceptedTemplate(): string {
1398 + return '<h2>Quote {{quote_number}} accepted</h2>
1216 1399
1217 1400 <p>Dear {{client_name}},</p>
1218 1401
1219 -<div class="success-box">
1220 - <p><strong>Quote #{{quote_number}} - ACCEPTED</strong><br>
1221 - <span class="amount-highlight">{{total_amount}}</span><br>
1222 - Acceptance Date: <strong>{{acceptance_date}}</strong></p>
1223 -</div>
1402 +<p>Thank you for accepting quote {{quote_number}} for <strong>{{total_amount}}</strong> on {{acceptance_date}}.</p>
1224 1403
1225 -<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>
1226 1405
1227 -<div class="info-box">
1228 - <p><strong>🚀 Next Steps:</strong><br>
1229 - • We will create an invoice for the accepted quote<br>
1230 - • You will receive payment instructions<br>
1231 - • Project work will begin as scheduled</p>
1232 -</div>
1233 -
1234 -<div class="highlight-box">
1235 - <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>
1236 -</div>
1237 -
1238 1406 <div class="divider"></div>
1239 1407
1240 -<p>Thank you for choosing our services!</p>
1408 +<p>Thank you for choosing us.</p>
1241 1409
1242 -<p>Best regards,<br>
1243 -<strong>{{company_name}}</strong><br>
1410 +<p>{{company_name}}<br>
1244 1411 {{company_email}}</p>';
1245 1412 }
1246 1413
1247 - private function getDefaultQuoteDeclinedTemplate(): string {
1248 - return '<h2>📝 Quote Response Received</h2>
1414 + private static function getDefaultQuoteDeclinedTemplate(): string {
1415 + return '<h2>Quote {{quote_number}}</h2>
1249 1416
1250 1417 <p>Dear {{client_name}},</p>
1251 1418
1252 -<div class="warning-box">
1253 - <p><strong>Quote #{{quote_number}} - DECLINED</strong><br>
1254 - Response Date: <strong>{{response_date}}</strong></p>
1255 -</div>
1419 +<p>Thank you for letting us know that quote {{quote_number}} is not going ahead ({{response_date}}).</p>
1256 1420
1257 -<p>We have received your response regarding our quote. We understand that this quote may not have met your current needs.</p>
1258 -
1259 1421 <div class="info-box">
1260 - <p><strong>📋 Feedback:</strong><br>
1261 - • Reason: {{decline_reason}}<br>
1262 - • Response Date: {{response_date}}</p>
1422 + <p>Reason given: {{decline_reason}}</p>
1263 1423 </div>
1264 1424
1265 -<div class="highlight-box">
1266 - <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>
1267 -</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>
1268 1426
1269 1427 <div class="divider"></div>
1270 1428
1271 -<p>Thank you for considering our services!</p>
1272 -
1273 -<p>Best regards,<br>
1274 -<strong>{{company_name}}</strong><br>
1429 +<p>{{company_name}}<br>
1275 1430 {{company_email}}</p>';
1276 1431 }
1277 1432
1278 1433 /**
@@ -1406,8 +1561,10 @@
1406 1561 return ['success' => false, 'message' => __('Payment email template not found', 'easy-invoice')];
1407 1562 }
1408 1563
1409 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);
1410 1567
1411 1568 // Check if email is enabled
1412 1569 if (!$template['enabled']) {
1413 1570 return ['success' => false, 'message' => __('Payment received email is disabled', 'easy-invoice')];
@@ -1423,8 +1580,17 @@
1423 1580 $email_data['message'],
1424 1581 $email_data['headers']
1425 1582 );
1426 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 +
1427 1593 if ($sent) {
1428 1594 // Log success
1429 1595 do_action('easy_invoice_payment_email_sent', $invoice, $client_email, $payment_data);
1430 1596
@@ -1443,7 +1609,576 @@
1443 1609 } catch (\Exception $e) {
1444 1610 $this->log('Payment email sending error: ' . $e->getMessage(), 'error');
1445 1611 return ['success' => false, 'message' => __('Error sending payment email: ', 'easy-invoice') . $e->getMessage()];
1446 1612 }
1613 + }
1614 +
1615 + /**
1616 + * Send admin notification when payment is received
1617 + *
1618 + * @param Invoice $invoice The invoice
1619 + * @param array $payment_data Payment data (method, amount, etc.)
1620 + * @return array Result array with success status and message
1621 + */
1622 + public function sendAdminPaymentNotification(Invoice $invoice, array $payment_data = []): array {
1623 + try {
1624 + // Validate invoice
1625 + if (!$invoice || !$invoice->getId()) {
1626 + return ['success' => false, 'message' => __('Invalid invoice', 'easy-invoice')];
1627 + }
1628 +
1629 + // Get admin email
1630 + $admin_email = $this->settings['admin_email'] ?? get_option('admin_email');
1631 + if (empty($admin_email)) {
1632 + return ['success' => false, 'message' => __('Admin email is missing', 'easy-invoice')];
1633 + }
1634 +
1635 + // Get payment method
1636 + $payment_method = $payment_data['payment_method'] ?? $payment_data['method'] ?? 'online';
1637 + $payment_method_label = $this->getPaymentMethodLabel($payment_method);
1638 +
1639 + // Format amount
1640 + $formatter = new \EasyInvoice\Helpers\InvoiceFormatter($invoice);
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());
1643 +
1644 + // Prepare email subject
1645 + $pending = !empty($payment_data['pending']);
1646 + $subject = sprintf(
1647 + /* translators: %s: document number. */
1648 + $pending ? __('Payment awaiting verification - Invoice #%s', 'easy-invoice') : __('New Payment Received - Invoice #%s', 'easy-invoice'),
1649 + $invoice->getNumber()
1650 + );
1651 +
1652 + // Prepare email message
1653 + $message = $this->prepareAdminPaymentNotificationMessage($invoice, $payment_method_label, $amount, $payment_data);
1654 +
1655 + // Add HTML wrapper if enabled
1656 + if ($this->settings['enable_html'] === 'yes') {
1657 + $message = $this->wrapInHtmlTemplate($message);
1658 + }
1659 +
1660 + // Prepare headers
1661 + $headers = $this->prepareEmailHeaders();
1662 +
1663 + // Send email
1664 + $sent = $this->sendEmail($admin_email, $subject, $message, $headers);
1665 +
1666 + if ($sent) {
1667 + do_action('easy_invoice_admin_payment_notification_sent', $invoice, $admin_email, $payment_data);
1668 + return [
1669 + 'success' => true,
1670 + 'message' => __('Admin notification sent successfully', 'easy-invoice')
1671 + ];
1672 + } else {
1673 + do_action('easy_invoice_admin_payment_notification_failed', $invoice, $admin_email, $payment_data);
1674 + return ['success' => false, 'message' => __('Failed to send admin notification', 'easy-invoice')];
1675 + }
1676 +
1677 + } catch (\Exception $e) {
1678 + $this->log('Admin payment notification error: ' . $e->getMessage(), 'error');
1679 + return ['success' => false, 'message' => __('Error sending admin notification: ', 'easy-invoice') . $e->getMessage()];
1680 + }
1681 + }
1682 +
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', $payment_id = 0): 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_id = (int) $payment_id;
1699 + $payment_data = [
1700 + 'payment_method' => (string) $payment_method,
1701 + 'pending' => true,
1702 + 'payment_id' => $payment_id,
1703 + ];
1704 + if ($payment_id) {
1705 + $payment_data['amount'] = (float) get_post_meta($payment_id, '_amount', true);
1706 + $payment_data['notes'] = (string) get_post_meta($payment_id, '_notes', true);
1707 + $payment_data['reference'] = (string) get_post_meta($payment_id, \EasyInvoice\Services\OfflinePayments::META_REFERENCE, true);
1708 + if (\EasyInvoice\Services\OfflinePayments::hasProof($payment_id)) {
1709 + $payment_data['proof_url'] = \EasyInvoice\Services\OfflinePayments::proofUrl($payment_id);
1710 + }
1711 + } else {
1712 + $notes = get_post_meta($invoice->getId(), '_manual_payment_notes', true);
1713 + if ($notes) {
1714 + $payment_data['notes'] = $notes;
1715 + }
1716 + }
1717 + $this->sendAdminPaymentNotification($invoice, $payment_data);
1718 + }
1719 +
1720 + /**
1721 + * Send payment confirmation email to customer
1722 + *
1723 + * @param Invoice $invoice The invoice
1724 + * @param array $payment_data Payment data
1725 + * @return array Result array with success status and message
1726 + */
1727 + public function sendPaymentConfirmationEmail(Invoice $invoice, array $payment_data = []): array {
1728 + try {
1729 + // Validate invoice
1730 + if (!$invoice || !$invoice->getId()) {
1731 + return ['success' => false, 'message' => __('Invalid invoice', 'easy-invoice')];
1732 + }
1733 +
1734 + // Get customer email
1735 + $customer_email = $invoice->getCustomerEmail();
1736 + if (empty($customer_email)) {
1737 + return ['success' => false, 'message' => __('Customer email is missing', 'easy-invoice')];
1738 + }
1739 +
1740 + // Get currency settings
1741 + $settings_controller = new \EasyInvoice\Controllers\SettingsController();
1742 + $settings = $settings_controller->getSettings();
1743 + $currency_code = $settings['easy_invoice_currency_code'] ?? 'USD';
1744 + $currency_symbol = \EasyInvoice\Helpers\CurrencyHelper::getCurrencySymbol($currency_code);
1745 +
1746 + // Format amount
1747 + $amount = $invoice->getTotal();
1748 + $formatted_amount = $currency_symbol . number_format($amount, 2);
1749 +
1750 + // Prepare email subject
1751 + $site_name = get_bloginfo('name');
1752 + $subject = sprintf(
1753 + /* translators: %1$s: site name; %2$s: document number. */
1754 + __('[%1$s] Payment Confirmed - Invoice #%2$s', 'easy-invoice'),
1755 + $site_name,
1756 + $invoice->getNumber()
1757 + );
1758 +
1759 + // Prepare email message
1760 + $message = $this->preparePaymentConfirmationMessage($invoice, $formatted_amount);
1761 +
1762 + // Add HTML wrapper if enabled
1763 + if ($this->settings['enable_html'] === 'yes') {
1764 + $message = $this->wrapInHtmlTemplate($message);
1765 + }
1766 +
1767 + // Prepare headers
1768 + $headers = $this->prepareEmailHeaders();
1769 +
1770 + // Add BCC to admin if enabled (but skip if this is from payment completion hook to avoid duplicate)
1771 + // The payment completion hook already sends a dedicated admin notification
1772 + $skip_bcc = isset($payment_data['skip_bcc']) && $payment_data['skip_bcc'] === true;
1773 + if (!$skip_bcc && $this->settings['bcc_admin'] === 'yes' && !empty($this->settings['admin_email'])) {
1774 + $headers[] = 'Bcc: ' . $this->settings['admin_email'];
1775 + }
1776 +
1777 + // Send email
1778 + $sent = $this->sendEmail($customer_email, $subject, $message, $headers);
1779 +
1780 + if ($sent) {
1781 + do_action('easy_invoice_payment_confirmation_sent', $invoice, $customer_email, $payment_data);
1782 + return [
1783 + 'success' => true,
1784 + 'message' => __('Payment confirmation email sent successfully', 'easy-invoice')
1785 + ];
1786 + } else {
1787 + do_action('easy_invoice_payment_confirmation_failed', $invoice, $customer_email, $payment_data);
1788 + return ['success' => false, 'message' => __('Failed to send payment confirmation email', 'easy-invoice')];
1789 + }
1790 +
1791 + } catch (\Exception $e) {
1792 + $this->log('Payment confirmation email error: ' . $e->getMessage(), 'error');
1793 + return ['success' => false, 'message' => __('Error sending payment confirmation: ', 'easy-invoice') . $e->getMessage()];
1794 + }
1795 + }
1796 +
1797 + /**
1798 + * Send payment rejection email to customer
1799 + *
1800 + * @param Invoice $invoice The invoice
1801 + * @param string $reason Rejection reason
1802 + * @return array Result array with success status and message
1803 + */
1804 + public function sendPaymentRejectionEmail(Invoice $invoice, string $reason = ''): array {
1805 + try {
1806 + // Validate invoice
1807 + if (!$invoice || !$invoice->getId()) {
1808 + return ['success' => false, 'message' => __('Invalid invoice', 'easy-invoice')];
1809 + }
1810 +
1811 + // Get customer email
1812 + $customer_email = $invoice->getCustomerEmail();
1813 + if (empty($customer_email)) {
1814 + return ['success' => false, 'message' => __('Customer email is missing', 'easy-invoice')];
1815 + }
1816 +
1817 + // Prepare email subject
1818 + $site_name = get_bloginfo('name');
1819 + $subject = sprintf(
1820 + /* translators: %1$s: site name; %2$s: document number. */
1821 + __('[%1$s] Payment Rejected - Invoice #%2$s', 'easy-invoice'),
1822 + $site_name,
1823 + $invoice->getNumber()
1824 + );
1825 +
1826 + // Prepare email message
1827 + $message = $this->preparePaymentRejectionMessage($invoice, $reason);
1828 +
1829 + // Add HTML wrapper if enabled
1830 + if ($this->settings['enable_html'] === 'yes') {
1831 + $message = $this->wrapInHtmlTemplate($message);
1832 + }
1833 +
1834 + // Prepare headers
1835 + $headers = $this->prepareEmailHeaders();
1836 +
1837 + // Add BCC to admin if enabled
1838 + if ($this->settings['bcc_admin'] === 'yes' && !empty($this->settings['admin_email'])) {
1839 + $headers[] = 'Bcc: ' . $this->settings['admin_email'];
1840 + }
1841 +
1842 + // Send email
1843 + $sent = $this->sendEmail($customer_email, $subject, $message, $headers);
1844 +
1845 + if ($sent) {
1846 + do_action('easy_invoice_payment_rejection_sent', $invoice, $customer_email, $reason);
1847 + return [
1848 + 'success' => true,
1849 + 'message' => __('Payment rejection email sent successfully', 'easy-invoice')
1850 + ];
1851 + } else {
1852 + do_action('easy_invoice_payment_rejection_failed', $invoice, $customer_email, $reason);
1853 + return ['success' => false, 'message' => __('Failed to send payment rejection email', 'easy-invoice')];
1854 + }
1855 +
1856 + } catch (\Exception $e) {
1857 + $this->log('Payment rejection email error: ' . $e->getMessage(), 'error');
1858 + return ['success' => false, 'message' => __('Error sending payment rejection: ', 'easy-invoice') . $e->getMessage()];
1859 + }
1860 + }
1861 +
1862 + /**
1863 + * Prepare admin payment notification message
1864 + *
1865 + * @param Invoice $invoice The invoice
1866 + * @param string $payment_method_label Payment method label
1867 + * @param string $amount Formatted amount
1868 + * @param array $payment_data Payment data
1869 + * @return string Email message
1870 + */
1871 + private function prepareAdminPaymentNotificationMessage(Invoice $invoice, string $payment_method_label, string $amount, array $payment_data = []): string {
1872 + $invoice_number = $invoice->getNumber();
1873 + $customer_name = $invoice->getCustomerName();
1874 + $customer_email = $invoice->getCustomerEmail();
1875 + $invoice_id = $invoice->getId();
1876 +
1877 + $pending = !empty($payment_data['pending']);
1878 + $message = sprintf(
1879 + /* translators: %1$s: payment method; %2$s: invoice number. */
1880 + $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'),
1881 + $payment_method_label,
1882 + $invoice_number
1883 + );
1884 + $message .= "\n\n";
1885 + /* translators: . */
1886 + $message .= __('Invoice Details:', 'easy-invoice');
1887 + $message .= "\n";
1888 + /* translators: %s: amount. */
1889 + $message .= sprintf($pending ? __('- Amount submitted: %s', 'easy-invoice') : __('- Amount received: %s', 'easy-invoice'), $amount);
1890 + $message .= "\n";
1891 + $ei_due = \EasyInvoice\Services\InvoiceBalance::due($invoice);
1892 + /* translators: %s: amount. */
1893 + $message .= sprintf(__('- Still owed: %s', 'easy-invoice'), (new \EasyInvoice\Helpers\InvoiceFormatter($invoice))->format($ei_due));
1894 + $message .= "\n";
1895 + /* translators: %s: customer name. */
1896 + $message .= sprintf(__('- Customer: %s', 'easy-invoice'), $customer_name);
1897 + $message .= "\n";
1898 + /* translators: %s: customer email address. */
1899 + $message .= sprintf(__('- Email: %s', 'easy-invoice'), $customer_email);
1900 +
1901 + if (!empty($payment_data['notes'])) {
1902 + $message .= "\n";
1903 + /* translators: %s: note left by the client. */
1904 + $message .= sprintf(__('- Client note: %s', 'easy-invoice'), $payment_data['notes']);
1905 + }
1906 +
1907 + // Add transaction ID if available
1908 + if (!empty($payment_data['transaction_id'])) {
1909 + $message .= "\n";
1910 + /* translators: %s: transaction id. */
1911 + $message .= sprintf(__('- Transaction ID: %s', 'easy-invoice'), $payment_data['transaction_id']);
1912 + }
1913 + if (!empty($payment_data['reference'])) {
1914 + $message .= "\n";
1915 + /* translators: %s: reference the client typed. */
1916 + $message .= sprintf(__('- Reference given by the client: %s', 'easy-invoice'), $payment_data['reference']);
1917 + }
1918 + if (!empty($payment_data['proof_url'])) {
1919 + $message .= "\n";
1920 + /* translators: %s: link. */
1921 + $message .= sprintf(__('- Receipt attached: %s', 'easy-invoice'), $payment_data['proof_url']);
1922 + } elseif ($pending) {
1923 + $message .= "\n";
1924 + $message .= __('- No receipt was attached.', 'easy-invoice');
1925 + }
1926 +
1927 + $message .= "\n\n";
1928 + $message .= $pending
1929 + ? __('Confirm or reject it on the Payments screen:', 'easy-invoice')
1930 + : __('Please review this payment in the admin dashboard:', 'easy-invoice');
1931 + $message .= "\n";
1932 + $message .= !empty($payment_data['payment_id'])
1933 + ? admin_url('admin.php?page=easy-invoice-payments&action=view&id=' . (int) $payment_data['payment_id'])
1934 + : admin_url('admin.php?page=easy-invoice-payments&status=pending');
1935 + $message .= "\n\n";
1936 + $message .= __('This is an automated message from Easy Invoice.', 'easy-invoice');
1937 +
1938 + return $message;
1939 + }
1940 +
1941 + /**
1942 + * Prepare payment confirmation message
1943 + *
1944 + * @param Invoice $invoice The invoice
1945 + * @param string $formatted_amount Formatted amount
1946 + * @return string Email message
1947 + */
1948 + private function preparePaymentConfirmationMessage(Invoice $invoice, string $formatted_amount): string {
1949 + $customer_name = $invoice->getCustomerName();
1950 + $invoice_number = $invoice->getNumber();
1951 + $site_name = get_bloginfo('name');
1952 + $company_name = get_option('easy_invoice_company_name', $site_name);
1953 +
1954 + /* translators: %s: customer name. */
1955 + /* translators: %s: customer name. */
1956 + $message = sprintf(__('Dear %s,', 'easy-invoice'), $customer_name);
1957 + $message .= "\n\n";
1958 + $message .= sprintf(
1959 + /* translators: %1$s: amount paid; %2$s: invoice number. */
1960 + __('We are pleased to confirm that your payment of %1$s for Invoice #%2$s has been received and processed successfully.', 'easy-invoice'),
1961 + $formatted_amount,
1962 + $invoice_number
1963 + );
1964 + $message .= "\n\n";
1965 + $message .= __('Thank you for your business.', 'easy-invoice');
1966 + $message .= "\n\n";
1967 + $message .= __('Regards,', 'easy-invoice');
1968 + $message .= "\n";
1969 + $message .= $company_name;
1970 +
1971 + return $message;
1972 + }
1973 +
1974 + /**
1975 + * Prepare payment rejection message
1976 + *
1977 + * @param Invoice $invoice The invoice
1978 + * @param string $reason Rejection reason
1979 + * @return string Email message
1980 + */
1981 + private function preparePaymentRejectionMessage(Invoice $invoice, string $reason = ''): string {
1982 + $customer_name = $invoice->getCustomerName();
1983 + $invoice_number = $invoice->getNumber();
1984 + $site_name = get_bloginfo('name');
1985 + $company_name = get_option('easy_invoice_company_name', $site_name);
1986 +
1987 + /* translators: %s: customer name. */
1988 + /* translators: %s: customer name. */
1989 + $message = sprintf(__('Dear %s,', 'easy-invoice'), $customer_name);
1990 + $message .= "\n\n";
1991 + $message .= sprintf(
1992 + /* translators: %s: invoice number. */
1993 + __('We regret to inform you that your payment for Invoice #%s has been rejected.', 'easy-invoice'),
1994 + $invoice_number
1995 + );
1996 +
1997 + if (!empty($reason)) {
1998 + $message .= "\n\n";
1999 + $message .= __('Reason:', 'easy-invoice');
2000 + $message .= "\n";
2001 + $message .= $reason;
2002 + }
2003 +
2004 + $message .= "\n\n";
2005 + $message .= __('Please contact us if you have any questions or concerns.', 'easy-invoice');
2006 + $message .= "\n\n";
2007 + $message .= __('Regards,', 'easy-invoice');
2008 + $message .= "\n";
2009 + $message .= $company_name;
2010 +
2011 + return $message;
2012 + }
2013 +
2014 + /**
2015 + * Send admin notification for quote acceptance/decline
2016 + *
2017 + * @param Quote $quote The quote
2018 + * @param string $action Action type ('accepted' or 'declined')
2019 + * @return array Result array with success status and message
2020 + */
2021 + public function sendAdminQuoteNotification(Quote $quote, string $action = 'accepted'): array {
2022 + try {
2023 + // Validate quote
2024 + if (!$quote || !$quote->getId()) {
2025 + return ['success' => false, 'message' => __('Invalid quote', 'easy-invoice')];
2026 + }
2027 +
2028 + // Get admin email
2029 + $admin_email = $this->settings['admin_email'] ?? get_option('admin_email');
2030 + if (empty($admin_email)) {
2031 + return ['success' => false, 'message' => __('Admin email is missing', 'easy-invoice')];
2032 + }
2033 +
2034 + // Prepare email subject
2035 + $subject = sprintf(
2036 + /* translators: %1$s: document number; %2$s: value. */
2037 + __('Quote %1$s has been %2$s', 'easy-invoice'),
2038 + $quote->getNumber(),
2039 + $action === 'accepted' ? __('accepted', 'easy-invoice') : __('declined', 'easy-invoice')
2040 + );
2041 +
2042 + // Prepare email message
2043 + $message = $this->prepareAdminQuoteNotificationMessage($quote, $action);
2044 +
2045 + // Add HTML wrapper if enabled
2046 + if ($this->settings['enable_html'] === 'yes') {
2047 + $message = $this->wrapInHtmlTemplate($message);
2048 + }
2049 +
2050 + // Prepare headers
2051 + $headers = $this->prepareEmailHeaders();
2052 +
2053 + // Send email
2054 + $sent = $this->sendEmail($admin_email, $subject, $message, $headers);
2055 +
2056 + if ($sent) {
2057 + do_action('easy_invoice_admin_quote_notification_sent', $quote, $admin_email, $action);
2058 + return [
2059 + 'success' => true,
2060 + 'message' => __('Admin notification sent successfully', 'easy-invoice')
2061 + ];
2062 + } else {
2063 + do_action('easy_invoice_admin_quote_notification_failed', $quote, $admin_email, $action);
2064 + return ['success' => false, 'message' => __('Failed to send admin notification', 'easy-invoice')];
2065 + }
2066 +
2067 + } catch (\Exception $e) {
2068 + $this->log('Admin quote notification error: ' . $e->getMessage(), 'error');
2069 + return ['success' => false, 'message' => __('Error sending admin notification: ', 'easy-invoice') . $e->getMessage()];
2070 + }
2071 + }
2072 +
2073 + /**
2074 + * Prepare admin quote notification message
2075 + *
2076 + * @param Quote $quote The quote
2077 + * @param string $action Action type ('accepted' or 'declined')
2078 + * @return string Email message
2079 + */
2080 + private function prepareAdminQuoteNotificationMessage(Quote $quote, string $action): string {
2081 + $site_name = get_bloginfo('name');
2082 + $quote_number = $quote->getNumber();
2083 + $customer_name = $quote->getCustomerName();
2084 +
2085 + // Format amount
2086 + $formatter = new \EasyInvoice\Helpers\InvoiceFormatter($quote);
2087 + $formatted_amount = $formatter->format($quote->getTotal());
2088 +
2089 + $action_label = $action === 'accepted' ? __('accepted', 'easy-invoice') : __('declined', 'easy-invoice');
2090 + $date_label = $action === 'accepted' ? __('Accepted Date', 'easy-invoice') : __('Declined Date', 'easy-invoice');
2091 +
2092 + /* translators: . */
2093 + $message = __('Hello,', 'easy-invoice');
2094 + $message .= "\n\n";
2095 + $message .= sprintf(
2096 + /* translators: %1$s: quote number; %2$s: quote title; %3$s: accepted or declined. */
2097 + __('The quote %1$s for %2$s has been %3$s by the client.', 'easy-invoice'),
2098 + $quote_number,
2099 + $customer_name,
2100 + $action_label
2101 + );
2102 + $message .= "\n\n";
2103 + /* translators: . */
2104 + $message .= __('Quote Details:', 'easy-invoice');
2105 + $message .= "\n";
2106 + /* translators: %s: quote number. */
2107 + $message .= sprintf(__('- Quote Number: %s', 'easy-invoice'), $quote_number);
2108 + $message .= "\n";
2109 + /* translators: %s: customer name. */
2110 + $message .= sprintf(__('- Client: %s', 'easy-invoice'), $customer_name);
2111 + $message .= "\n";
2112 + /* translators: %s: amount. */
2113 + $message .= sprintf(__('- Total Amount: %s', 'easy-invoice'), $formatted_amount);
2114 + $message .= "\n";
2115 + /* translators: %1$s: label such as "Accepted on"; %2$s: date and time. */
2116 + $message .= sprintf(__('- %1$s: %2$s', 'easy-invoice'), $date_label, date_i18n(get_option('date_format') . ' ' . get_option('time_format')));
2117 + $message .= "\n\n";
2118 + $message .= __('You can view the quote at:', 'easy-invoice');
2119 + $message .= "\n";
2120 + $message .= get_permalink($quote->getId());
2121 + $message .= "\n\n";
2122 + $message .= __('Best regards,', 'easy-invoice');
2123 + $message .= "\n";
2124 + $message .= $site_name;
2125 +
2126 + return $message;
2127 + }
2128 +
2129 + /**
2130 + * Handle payment completed hook
2131 + * Sends admin notification and customer confirmation when payment is completed
2132 + *
2133 + * @param int $invoice_id Invoice ID
2134 + * @param \EasyInvoice\Models\Invoice $invoice Invoice object
2135 + * @param array $payment_data Payment data (method, gateway, transaction_id, amount)
2136 + * @return void
2137 + */
2138 + public function handlePaymentCompleted(int $invoice_id, $invoice, array $payment_data = []): void {
2139 + if (!$invoice || !$invoice->getId()) {
2140 + return;
2141 + }
2142 +
2143 + // Send admin notification
2144 + $this->sendAdminPaymentNotification($invoice, $payment_data);
2145 +
2146 + // Send customer confirmation email using proper template system
2147 + // Check if payment email is enabled first
2148 + if (isset($this->templates['invoice_paid']) && $this->templates['invoice_paid']['enabled']) {
2149 + $this->sendInvoiceEmail($invoice, 'paid', array_merge($payment_data, ['skip_bcc' => true, 'payment_receipt' => true]));
2150 + }
2151 + }
2152 +
2153 + /**
2154 + * Get payment method label
2155 + *
2156 + * @param string $method Payment method
2157 + * @return string Payment method label
2158 + */
2159 + private function getPaymentMethodLabel(string $method): string {
2160 + if (class_exists('\\EasyInvoice\\Services\\OfflinePayments') && \EasyInvoice\Services\OfflinePayments::isOffline($method)) {
2161 + return \EasyInvoice\Services\OfflinePayments::label($method);
2162 + }
2163 + $labels = [
2164 + 'bank' => __('Bank Transfer', 'easy-invoice'),
2165 + 'bank_transfer' => __('Bank Transfer', 'easy-invoice'),
2166 + 'cash' => __('Cash', 'easy-invoice'),
2167 + 'check' => __('Cheque', 'easy-invoice'),
2168 + 'cheque' => __('Cheque', 'easy-invoice'),
2169 + 'paystack' => __('Paystack', 'easy-invoice'),
2170 + 'moneris' => __('Moneris', 'easy-invoice'),
2171 + 'other' => __('Other', 'easy-invoice'),
2172 + 'paypal' => __('PayPal', 'easy-invoice'),
2173 + 'stripe' => __('Stripe', 'easy-invoice'),
2174 + 'square' => __('Square', 'easy-invoice'),
2175 + 'mollie' => __('Mollie', 'easy-invoice'),
2176 + 'authorizenet' => __('Authorize.Net', 'easy-invoice'),
2177 + 'manual' => __('Manual Payment', 'easy-invoice'),
2178 + 'online' => __('Online Payment', 'easy-invoice'),
2179 + ];
2180 +
2181 + return $labels[$method] ?? ucfirst($method);
1447 2182 }
1448 2183
1449 2184 }