loadSettings(); $this->loadTemplates(); $this->initHooks(); } /** * Initialize hooks */ private function initHooks(): void { // Invoice/quote send-email AJAX is handled exclusively by EasyInvoice\Admin\EasyInvoiceAjax // (published-document checks, single handler) to avoid duplicate nopriv callbacks. // Add email settings to admin add_action('admin_init', [$this, 'registerEmailSettings']); add_action('admin_init', [__CLASS__, 'refreshStockTemplates']); // Refresh settings when they're updated add_action('update_option_easy_invoice_email_from_name', [$this, 'refreshSettings']); add_action('update_option_easy_invoice_email_from_address', [$this, 'refreshSettings']); add_action('update_option_easy_invoice_email_reply_to', [$this, 'refreshSettings']); add_action('update_option_easy_invoice_email_reply_to_name', [$this, 'refreshSettings']); add_action('update_option_easy_invoice_enable_email_styling', [$this, 'refreshSettings']); add_action('update_option_easy_invoice_email_logo', [$this, 'refreshSettings']); add_action('update_option_easy_invoice_email_footer_text', [$this, 'refreshSettings']); add_action('update_option_easy_invoice_bcc_admin', [$this, 'refreshSettings']); add_action('update_option_easy_invoice_admin_email', [$this, 'refreshSettings']); add_action('update_option_easy_invoice_email_subject', [$this, 'refreshSettings']); add_action('update_option_easy_invoice_email_body', [$this, 'refreshSettings']); add_action('update_option_easy_invoice_quote_subject', [$this, 'refreshSettings']); add_action('update_option_easy_invoice_quote_body', [$this, 'refreshSettings']); // Add email logs add_action('easy_invoice_email_sent', [$this, 'logEmailSent'], 10, 3); add_action('easy_invoice_email_failed', [$this, 'logEmailFailed'], 10, 3); // Listen for payment completion to send admin notifications add_action('easy_invoice_payment_completed', [$this, 'handlePaymentCompleted'], 10, 3); // An instalment gets a receipt as well; the invoice just is not settled yet. add_action('easy_invoice_payment_received', [$this, 'handlePaymentCompleted'], 10, 3); // A client has submitted a manual payment (bank transfer, cheque, // cash, with or without proof) that now waits for verification. The // only listener used to live in an admin class nothing instantiates, // so the admin was never told. add_action('easy_invoice_manual_payment_submitted', [$this, 'handleManualPaymentSubmitted'], 10, 3); } /** * Load email settings */ private function loadSettings(): void { $this->settings = [ 'from_name' => get_option('easy_invoice_email_from_name', get_bloginfo('name')), 'from_email' => get_option('easy_invoice_email_from_address', get_bloginfo('admin_email')), 'reply_to_email' => get_option('easy_invoice_email_reply_to', ''), 'reply_to_name' => get_option('easy_invoice_email_reply_to_name', ''), 'enable_html' => get_option('easy_invoice_enable_email_styling', 'yes'), 'email_logo' => get_option('easy_invoice_email_logo', ''), 'footer_text' => get_option('easy_invoice_email_footer_text', ''), 'bcc_admin' => get_option('easy_invoice_bcc_admin', 'no'), 'admin_email' => get_option('easy_invoice_admin_email', get_option('admin_email')), ]; } /** * Load email templates */ private function loadTemplates(): void { $this->templates = [ 'invoice_new' => [ 'enabled' => get_option('easy_invoice_invoice_email_enabled', 'yes') === 'yes', 'subject' => get_option('easy_invoice_invoice_email_subject', __('Your Invoice #{{invoice_number}} from {{company_name}}', 'easy-invoice')), 'body' => get_option('easy_invoice_invoice_email_body', $this->getDefaultInvoiceTemplate()), 'type' => 'invoice' ], 'invoice_reminder' => [ 'enabled' => get_option('easy_invoice_invoice_email_enabled', 'yes') === 'yes', 'subject' => get_option('easy_invoice_reminder_subject', 'Payment Reminder - Invoice #{{invoice_number}}'), 'body' => get_option('easy_invoice_reminder_body', $this->getDefaultReminderTemplate()), 'type' => 'invoice' ], 'invoice_paid' => [ 'enabled' => get_option('easy_invoice_payment_email_enabled', 'yes') === 'yes', 'subject' => get_option('easy_invoice_payment_email_subject', __('Payment Received - Invoice #{{invoice_number}}', 'easy-invoice')), 'body' => get_option('easy_invoice_payment_email_body', $this->getDefaultPaymentTemplate()), 'type' => 'invoice' ], 'quote_new' => [ 'enabled' => get_option('easy_invoice_quote_email_enabled', 'yes') === 'yes', 'subject' => get_option('easy_invoice_quote_email_subject', __('Your Quote #{{quote_number}} from {{company_name}}', 'easy-invoice')), 'body' => get_option('easy_invoice_quote_email_body', $this->getDefaultQuoteTemplate()), 'type' => 'quote' ], 'quote_accepted' => [ 'enabled' => get_option('easy_invoice_quote_email_enabled', 'yes') === 'yes', 'subject' => get_option('easy_invoice_quote_accepted_subject', 'Quote Accepted - #{{quote_number}}'), 'body' => get_option('easy_invoice_quote_accepted_body', $this->getDefaultQuoteAcceptedTemplate()), 'type' => 'quote' ], 'quote_declined' => [ 'enabled' => get_option('easy_invoice_quote_email_enabled', 'yes') === 'yes', 'subject' => get_option('easy_invoice_quote_declined_subject', 'Quote Declined - #{{quote_number}}'), 'body' => get_option('easy_invoice_quote_declined_body', $this->getDefaultQuoteDeclinedTemplate()), 'type' => 'quote' ] ]; } /** * Send invoice email * * @param Invoice $invoice The invoice * @param string $template_type Template type (new, reminder, paid) * @param array $additional_data Additional data for template * @return array Result array with success status and message */ public function sendInvoiceEmail(Invoice $invoice, string $template_type = 'new', array $additional_data = []): array { try { // Validate invoice if (!$invoice || !$invoice->getId()) { return ['success' => false, 'message' => __('Invalid invoice', 'easy-invoice')]; } // Get client email $client_email = $invoice->getCustomerEmail(); if (empty($client_email)) { return ['success' => false, 'message' => __('Client email is missing', 'easy-invoice')]; } // Get template $template_key = 'invoice_' . $template_type; if (!isset($this->templates[$template_key])) { return ['success' => false, 'message' => __('Email template not found', 'easy-invoice')]; } $template = $this->templates[$template_key]; /** * Filter an email template before subject and body are built. * * Runs for invoice, quote and payment emails, so a listener can * switch locale for the client or swap the template wholesale. * `easy_invoice_email_finished` fires once the send is over. * * @param array $template subject, body, enabled. * @param string $template_key invoice_new, quote_reminder, invoice_paid… * @param object $document Invoice or Quote model. */ $template = (array) apply_filters('easy_invoice_email_template_data', $template, $template_key, $invoice); // Check if email is enabled if (!$template['enabled']) { return ['success' => false, 'message' => __('Invoice available email is disabled', 'easy-invoice')]; } // Prepare email data $email_data = $this->prepareInvoiceEmailData($invoice, $template, $additional_data); // Send email // Attach the invoice as a PDF, when the site has asked for it. // // This is the capability the browser-based renderer could never provide: // wp_mail() needs a file on disk, and until PdfRenderer existed the server // never held the document. Off by default so an upgrade does not silently // change what customers receive. $attachments = []; $attached_path = ''; if ($this->shouldAttachInvoicePdf()) { $rendered = \EasyInvoice\Services\PdfRenderer::renderToFile($invoice, 'invoice'); if (is_wp_error($rendered)) { // A failed attachment must never stop the invoice being sent. error_log('Easy Invoice: could not attach invoice PDF — ' . $rendered->get_error_message()); } else { $attached_path = $rendered; $attachments[] = $rendered; } } /** * Filter the files sent with an invoice email. * * @param array $attachments Paths. * @param object $invoice Invoice model. * @param string $type 'invoice'. */ $attachments = (array) apply_filters( 'easy_invoice_email_attachments', $attachments, $invoice, 'invoice' ); $email_data['message'] = $this->attachmentWording($email_data['message'], !empty($attachments)); $sent = $this->sendEmail( $email_data['to'], $email_data['subject'], $email_data['message'], $email_data['headers'], $attachments ); // The rendered PDF lives in the system temp directory; remove it once // wp_mail() has handed it to the transport. if ($attached_path !== '' && file_exists($attached_path)) { wp_delete_file($attached_path); } /** * Fires once an email send has finished, whether or not it went out. * * @param object $document Invoice or Quote model. * @param string $template_key Template key. * @param bool $sent Whether wp_mail() accepted it. */ do_action('easy_invoice_email_finished', $invoice, $template_key, (bool) $sent); if ($sent) { // Log success do_action('easy_invoice_email_sent', $invoice, $client_email, $template_type); // Emailing a draft issues it: from here on it is a document // the client holds, so it reads Available, is chased by // reminders and is corrected by credit note, not by editing. if ('new' === $template_type && 'draft' === strtolower((string) $invoice->getStatus())) { $invoice->setStatus('available'); $invoice->save(); } return [ 'success' => true, 'message' => __('Email sent successfully', 'easy-invoice'), 'email_data' => $email_data ]; } else { // Log failure do_action('easy_invoice_email_failed', $invoice, $client_email, $template_type); return ['success' => false, 'message' => __('Failed to send email', 'easy-invoice')]; } } catch (\Exception $e) { $this->log('Email sending error: ' . $e->getMessage(), 'error'); return ['success' => false, 'message' => __('Error sending email: ', 'easy-invoice') . $e->getMessage()]; } } /** * Send quote email * * @param Quote $quote The quote * @param string $template_type Template type (new, accepted, declined) * @param array $additional_data Additional data for template * @return array Result array with success status and message */ public function sendQuoteEmail(Quote $quote, string $template_type = 'new', array $additional_data = []): array { try { // Validate quote if (!$quote || !$quote->getId()) { return ['success' => false, 'message' => __('Invalid quote', 'easy-invoice')]; } // Get client email $client_email = $quote->getCustomerEmail(); if (empty($client_email)) { return ['success' => false, 'message' => __('Client email is missing', 'easy-invoice')]; } // Get template $template_key = 'quote_' . $template_type; if (!isset($this->templates[$template_key])) { return ['success' => false, 'message' => __('Email template not found', 'easy-invoice')]; } $template = $this->templates[$template_key]; /** This filter is documented above in sendInvoiceEmail(). */ $template = (array) apply_filters('easy_invoice_email_template_data', $template, $template_key, $quote); // Check if email is enabled if (!$template['enabled']) { return ['success' => false, 'message' => __('Quote available email is disabled', 'easy-invoice')]; } // Prepare email data $email_data = $this->prepareQuoteEmailData($quote, $template, $additional_data); // Same setting as invoices: a PDF copy goes with the quote when asked for. $attachments = []; $attached_path = ''; if ($this->shouldAttachInvoicePdf()) { $rendered = \EasyInvoice\Services\PdfRenderer::renderToFile($quote, 'quote'); if (is_wp_error($rendered)) { error_log('Easy Invoice: could not attach quote PDF — ' . $rendered->get_error_message()); } else { $attached_path = $rendered; $attachments[] = $rendered; } } $attachments = (array) apply_filters( 'easy_invoice_email_attachments', $attachments, $quote, 'quote' ); $email_data['message'] = $this->attachmentWording($email_data['message'], !empty($attachments)); // Send email $sent = $this->sendEmail( $email_data['to'], $email_data['subject'], $email_data['message'], $email_data['headers'], $attachments ); if ($attached_path !== '' && file_exists($attached_path)) { wp_delete_file($attached_path); } /** * Fires once an email send has finished, whether or not it went out. * * @param object $document Invoice or Quote model. * @param string $template_key Template key. * @param bool $sent Whether wp_mail() accepted it. */ do_action('easy_invoice_email_finished', $quote, $template_key, (bool) $sent); if ($sent) { // Log success do_action('easy_invoice_quote_email_sent', $quote, $client_email, $template_type); // A quote that has been emailed is "sent". if ('new' === $template_type && in_array(strtolower((string) $quote->getStatus()), ['draft', 'available'], true)) { $quote->setStatus('sent'); $quote->save(); } return [ 'success' => true, 'message' => __('Quote email sent successfully', 'easy-invoice'), 'email_data' => $email_data ]; } else { // Log failure do_action('easy_invoice_quote_email_failed', $quote, $client_email, $template_type); return ['success' => false, 'message' => __('Failed to send quote email', 'easy-invoice')]; } } catch (\Exception $e) { $this->log('Quote email sending error: ' . $e->getMessage(), 'error'); return ['success' => false, 'message' => __('Error sending quote email: ', 'easy-invoice') . $e->getMessage()]; } } /** * Prepare invoice email data * * @param Invoice $invoice The invoice * @param array $template The email template * @param array $additional_data Additional data * @return array Email data */ private function prepareInvoiceEmailData(Invoice $invoice, array $template, array $additional_data = []): array { // Get replacements — the receipt needs the payment placeholders too. $replacements = !empty($additional_data['payment_receipt']) ? $this->getPaymentReplacements($invoice, $additional_data) : $this->getInvoiceReplacements($invoice, $additional_data); // Process template $subject = $this->processTemplate($template['subject'], $replacements); $message = $this->processTemplate($template['body'], $replacements); $message = do_shortcode($message); // Render shortcodes like [easy_invoice_url ...] // Add HTML wrapper if enabled if ($this->settings['enable_html'] === 'yes') { $message = $this->wrapInHtmlTemplate($message); } // Prepare headers $headers = $this->prepareEmailHeaders('invoice', $invoice); // Add BCC to admin if enabled if ($this->settings['bcc_admin'] === 'yes' && !empty($this->settings['admin_email'])) { $headers[] = 'Bcc: ' . $this->settings['admin_email']; } return [ 'to' => $invoice->getCustomerEmail(), 'subject' => $subject, 'message' => $message, 'headers' => $headers ]; } /** * Prepare quote email data * * @param Quote $quote The quote * @param array $template The email template * @param array $additional_data Additional data * @return array Email data */ private function prepareQuoteEmailData(Quote $quote, array $template, array $additional_data = []): array { // Get replacements $replacements = $this->getQuoteReplacements($quote, $additional_data); // Process template $subject = $this->processTemplate($template['subject'], $replacements); $message = $this->processTemplate($template['body'], $replacements); // Add HTML wrapper if enabled if ($this->settings['enable_html'] === 'yes') { $message = $this->wrapInHtmlTemplate($message); } // Prepare headers $headers = $this->prepareEmailHeaders('quote', $quote); // Add BCC to admin if enabled if ($this->settings['bcc_admin'] === 'yes' && !empty($this->settings['admin_email'])) { $headers[] = 'Bcc: ' . $this->settings['admin_email']; } return [ 'to' => $quote->getCustomerEmail(), 'subject' => $subject, 'message' => $message, 'headers' => $headers ]; } /** * Prepare payment email data * * @param Invoice $invoice The invoice * @param array $template The email template * @param array $payment_data Payment data * @return array Email data */ private function preparePaymentEmailData(Invoice $invoice, array $template, array $payment_data = []): array { // Get replacements $replacements = $this->getPaymentReplacements($invoice, $payment_data); // Process template $subject = $this->processTemplate($template['subject'], $replacements); $message = $this->processTemplate($template['body'], $replacements); // Add HTML wrapper if enabled if ($this->settings['enable_html'] === 'yes') { $message = $this->wrapInHtmlTemplate($message); } // Prepare headers $headers = $this->prepareEmailHeaders('receipt', $invoice); // Add BCC to admin if enabled if ($this->settings['bcc_admin'] === 'yes' && !empty($this->settings['admin_email'])) { $headers[] = 'Bcc: ' . $this->settings['admin_email']; } return [ 'to' => $invoice->getCustomerEmail(), 'subject' => $subject, 'message' => $message, 'headers' => $headers ]; } /** * Get invoice replacements * * @param Invoice $invoice The invoice * @param array $additional_data Additional data * @return array Replacements */ private function getInvoiceReplacements(Invoice $invoice, array $additional_data = []): array { $currency_symbol = get_option('easy_invoice_currency_symbol', '$'); // Secure link support $invoice_url = get_permalink($invoice->getId()); $secure_links_enabled = get_option('easy_invoice_pro_enable_secure_links', 'no') === 'yes'; if ($secure_links_enabled && class_exists('\EasyInvoicePro\Addons\SecureLinks\Controllers\PermalinkController')) { $secure_url = \EasyInvoicePro\Addons\SecureLinks\Controllers\PermalinkController::getInvoiceSecureLinkUrl($invoice->getId()); if ($secure_url) { $invoice_url = $secure_url; } } // SECURITY: attach a per-invoice access token to the outbound URL // so the legitimate email recipient can submit manual payments // without needing to log in. The token is verified server-side in // PaymentController::submitManualPayment via // InvoiceController::canSubmitPaymentForInvoice. Empty-token // guard so a CSPRNG failure doesn't produce malformed `?ik=` URLs. $invoice_access_token = \EasyInvoice\Controllers\InvoiceController::invoiceAccessToken((int) $invoice->getId()); if ($invoice_access_token !== '' && $invoice_url) { $invoice_url = add_query_arg('ik', $invoice_access_token, $invoice_url); // The recipient now holds a keyed link; the bare one may close (TemplateLoader::isLegacyOpenDocument). \EasyInvoice\TemplateLoader::markKeyedLinkSent((int) $invoice->getId()); } // Get client data for additional fields $client = null; if ($invoice->getClientId()) { $client_repository = new \EasyInvoice\Repositories\ClientRepository(); $client = $client_repository->find($invoice->getClientId()); } return array_merge([ '{{invoice_number}}' => $invoice->getNumber(), '{{invoice_title}}' => $invoice->getTitle(), '{{client_name}}' => $invoice->getCustomerName(), '{{client_email}}' => $invoice->getCustomerEmail(), '{{client_address}}' => $invoice->getCustomerAddress(), '{{client_first_name}}' => $client ? $client->getFirstName() : '', '{{client_last_name}}' => $client ? $client->getLastName() : '', '{{company_name}}' => get_option('easy_invoice_company_name') ?: get_bloginfo('name'), '{{company_email}}' => $this->settings['from_email'], '{{company_phone}}' => get_option('easy_invoice_company_phone', ''), '{{company_address}}' => get_option('easy_invoice_company_address', ''), '{{company_website}}' => get_option('easy_invoice_company_website', ''), '{{total_amount}}' => (new \EasyInvoice\Helpers\InvoiceFormatter($invoice))->format($invoice->getTotal()), '{{amount_due}}' => (new \EasyInvoice\Helpers\InvoiceFormatter($invoice))->format(\EasyInvoice\Services\InvoiceBalance::due($invoice)), '{{subtotal}}' => (new \EasyInvoice\Helpers\InvoiceFormatter($invoice))->format($invoice->getSubtotal()), '{{tax_amount}}' => (new \EasyInvoice\Helpers\InvoiceFormatter($invoice))->format($invoice->getTaxAmount()), '{{discount_amount}}' => (new \EasyInvoice\Helpers\InvoiceFormatter($invoice))->format($invoice->getDiscountAmount()), '{{due_date}}' => gmdate('F j, Y', strtotime($invoice->getDueDate())), '{{issue_date}}' => gmdate('F j, Y', strtotime($invoice->getIssueDate())), '{{invoice_url}}' => $invoice_url, // Use correct (possibly secure) link '{{payment_url}}' => add_query_arg('payment', '1', get_permalink($invoice->getId())), '{{site_url}}' => get_site_url(), '{{admin_url}}' => admin_url(), '{{payment_terms}}' => get_option('easy_invoice_payment_terms', __('Due on receipt', 'easy-invoice')), ], self::placeholderKeysOnly($additional_data)); } /** * Keep only entries shaped like placeholders. Callers pass raw payment * data ('amount', 'date', 'payment_method') alongside; merged as-is those * became replacements of the bare words, turning "{{payment_amount}}" * into "{{payment_40}}" and every "date" in the text into a date. * * @param array $data Mixed data. * @return array */ private static function placeholderKeysOnly(array $data): array { $out = []; foreach ($data as $key => $value) { if (is_string($key) && 0 === strpos($key, '{{') && is_scalar($value)) { $out[$key] = (string) $value; } } return $out; } /** * Get quote replacements * * @param Quote $quote The quote * @param array $additional_data Additional data * @return array Replacements */ private function getQuoteReplacements(Quote $quote, array $additional_data = []): array { $currency_symbol = get_option('easy_invoice_currency_symbol', '$'); $quote_url = get_permalink($quote->getId()); $secure_links_enabled = get_option('easy_invoice_pro_enable_secure_links', 'no') === 'yes'; if ($secure_links_enabled && class_exists('\EasyInvoicePro\Addons\SecureLinks\Controllers\PermalinkController')) { $secure_url = \EasyInvoicePro\Addons\SecureLinks\Controllers\PermalinkController::getQuoteSecureLinkUrl($quote->getId()); if ($secure_url) { $quote_url = $secure_url; } } // SECURITY (CVE-2026-9021): attach the per-quote access token so // the emailed recipient lands on a page that renders the // Accept/Decline UI and can submit either action without // authenticating. Without the token the public single-quote page // is read-only (no buttons, no nonce in DOM). Lazily generates // the token on first send. The query parameter name is // intentionally short ('qk') and opaque — leaking it via referer // headers is no worse than leaking the secure-link signature. $quote_access_token = \EasyInvoice\Controllers\QuoteController::quoteAccessToken((int) $quote->getId()); if ($quote_access_token !== '' && $quote_url) { $quote_url = add_query_arg('qk', $quote_access_token, $quote_url); \EasyInvoice\TemplateLoader::markKeyedLinkSent((int) $quote->getId()); } // Get client data for additional fields $client = null; if ($quote->getClientId()) { $client_repository = new \EasyInvoice\Repositories\ClientRepository(); $client = $client_repository->find($quote->getClientId()); } return array_merge([ '{{quote_number}}' => $quote->getNumber(), '{{quote_title}}' => $quote->getTitle(), '{{client_name}}' => $quote->getCustomerName(), '{{client_email}}' => $quote->getCustomerEmail(), '{{client_address}}' => $quote->getCustomerAddress(), '{{client_first_name}}' => $client ? $client->getFirstName() : '', '{{client_last_name}}' => $client ? $client->getLastName() : '', '{{company_name}}' => get_option('easy_invoice_company_name') ?: get_bloginfo('name'), '{{company_email}}' => $this->settings['from_email'], '{{company_phone}}' => get_option('easy_invoice_company_phone', ''), '{{company_address}}' => get_option('easy_invoice_company_address', ''), '{{company_website}}' => get_option('easy_invoice_company_website', ''), '{{total_amount}}' => (new \EasyInvoice\Helpers\QuoteFormatter($quote))->format($quote->getTotal()), '{{subtotal}}' => (new \EasyInvoice\Helpers\QuoteFormatter($quote))->format($quote->getSubtotal()), '{{tax_amount}}' => (new \EasyInvoice\Helpers\QuoteFormatter($quote))->format($quote->getTaxAmount()), '{{discount_amount}}' => (new \EasyInvoice\Helpers\QuoteFormatter($quote))->format($quote->getDiscountAmount()), '{{expiry_date}}' => gmdate('F j, Y', strtotime($quote->getExpiryDate())), '{{issue_date}}' => gmdate('F j, Y', strtotime($quote->getIssueDate())), '{{quote_url}}' => $quote_url, '{{site_url}}' => get_site_url(), '{{admin_url}}' => admin_url(), '{{payment_terms}}' => get_option('easy_invoice_payment_terms', __('Due on receipt', 'easy-invoice')), ], self::placeholderKeysOnly($additional_data)); } /** * Get payment replacements * * @param Invoice $invoice The invoice * @param array $payment_data Payment data * @return array Replacements */ private function getPaymentReplacements(Invoice $invoice, array $payment_data = []): array { $replacements = $this->getInvoiceReplacements($invoice, $payment_data); // Add payment-specific replacements $formatter = new \EasyInvoice\Helpers\InvoiceFormatter($invoice); $replacements['{{payment_amount}}'] = isset($payment_data['amount']) ? $formatter->format((float) $payment_data['amount']) : $formatter->format($invoice->getTotal()); $paid_on = !empty($payment_data['date']) ? strtotime((string) $payment_data['date']) : false; $replacements['{{payment_date}}'] = date_i18n(get_option('date_format'), $paid_on ?: current_time('timestamp')); // Callers pass the gateway id as payment_method (some as method); show its label. $method_key = (string) ($payment_data['payment_method'] ?? $payment_data['method'] ?? ''); $replacements['{{payment_method}}'] = '' !== $method_key ? $this->getPaymentMethodLabel($method_key) : __('Online Payment', 'easy-invoice'); $replacements['{{transaction_id}}'] = !empty($payment_data['transaction_id']) ? (string) $payment_data['transaction_id'] : __('N/A', 'easy-invoice'); $replacements['{{acceptance_date}}'] = isset($payment_data['acceptance_date']) ? $payment_data['acceptance_date'] : current_time('Y-m-d'); $replacements['{{response_date}}'] = isset($payment_data['response_date']) ? $payment_data['response_date'] : current_time('Y-m-d'); $replacements['{{decline_reason}}'] = isset($payment_data['decline_reason']) ? $payment_data['decline_reason'] : __('No specific reason provided', 'easy-invoice'); return $replacements; } /** * Process template with replacements * * @param string $template The template * @param array $replacements The replacements * @return string Processed template */ private function processTemplate(string $template, array $replacements): string { return easy_invoice_str_replace(array_keys($replacements), array_values($replacements), $template); } /** * Prepare email headers * * @return array Headers */ /** * Should outgoing invoice emails carry a PDF copy? * * Defaults to off. Attaching a document changes what every customer receives and * makes messages substantially larger, which some SMTP relays limit — that is the * site owner's decision, not something an update should impose. * * @return bool */ private function shouldAttachInvoicePdf(): bool { if (!\EasyInvoice\Services\PdfRenderer::isAvailable()) { return false; } $enabled = get_option('easy_invoice_attach_pdf_to_email', 'no') === 'yes'; /** * Filter whether to attach a PDF to invoice emails. * * @param bool $enabled Current setting. */ return (bool) apply_filters('easy_invoice_attach_pdf_to_email', $enabled); } private function prepareEmailHeaders(string $template_name = '', $document = null): array { $headers = [ 'Content-Type: text/html; charset=UTF-8', 'From: ' . $this->settings['from_name'] . ' <' . $this->settings['from_email'] . '>', ]; // Add Reply-To if set if (!empty($this->settings['reply_to_email'])) { $reply_to_name = !empty($this->settings['reply_to_name']) ? $this->settings['reply_to_name'] : $this->settings['from_name']; $headers[] = 'Reply-To: ' . $reply_to_name . ' <' . $this->settings['reply_to_email'] . '>'; } /** * Filter the headers of an outgoing Easy Invoice email. * * This is the extension point Easy Invoice Pro's Email Enhancements addon uses * to set a per-document-type Reply-To. The addon has always registered against * it, but nothing here ever applied it, so that half of the addon did nothing * at all — the Reply-To customers saw came only from the free plugin's own * Email settings above. * * @param array $headers Headers assembled so far. * @param string $template_name Which email this is: invoice, quote, receipt, * reminder, and so on. Empty when the caller has * no template context. * @param mixed $document The Invoice or Quote the email concerns, or null. */ return (array) apply_filters('easy_invoice_email_headers', $headers, $template_name, $document); } /** * Wrap message in HTML template * * @param string $message The message * @return string HTML wrapped message */ /** * Wrap a message body in the plugin's HTML email layout (logo, styles, * footer) — for anything outside this class that sends a branded email. * * @param string $message Body HTML. * @return string */ /** * The stock templates mention an attached copy. When nothing is attached * (the setting is off by default) that sentence would be untrue, so the * exact stock phrases are reworded; a merchant's own text is left alone. * * @param string $message Rendered email body. * @param bool $attached Whether a file goes with it. * @return string */ private function attachmentWording(string $message, bool $attached): string { if ($attached) { return $message; } return str_replace( [ __('The invoice is attached and can also be viewed and paid online:', 'easy-invoice'), __('It is attached, and you can review, accept or decline it online:', 'easy-invoice'), ], [ __('You can view and pay it online:', 'easy-invoice'), __('You can review, accept or decline it online:', 'easy-invoice'), ], $message ); } public function wrapMessage(string $message): string { return $this->wrapInHtmlTemplate($message); } /** * Headers for an email sent by something other than this class (Pro's * reminders, addons): From and Reply-To from Settings → Email, then the * `easy_invoice_email_headers` filter with the template name. * * @param string $template_name invoice, quote, receipt, reminder… * @param mixed $document The Invoice or Quote concerned, or null. * @return array */ public function headers(string $template_name = '', $document = null): array { return $this->prepareEmailHeaders($template_name, $document); } /** * Placeholder replacements for an invoice, for a template sent by * something other than this class (Pro's reminders, addons). * * @param object $invoice Invoice model. * @return array */ public function invoicePlaceholders($invoice): array { return $this->getInvoiceReplacements($invoice); } private function wrapInHtmlTemplate(string $message): string { $logo_html = ''; if (!empty($this->settings['email_logo'])) { $logo_html = '
' . esc_attr($this->settings['from_name']) . '
'; } $footer_html = ''; if (!empty($this->settings['footer_text'])) { $footer_html = '
' . wpautop($this->settings['footer_text']) . '
'; } /** * Filter the footer block of every Easy Invoice email. * * @param string $footer_html The footer markup ('' when no footer text is set). * @param array $settings Email settings. */ $footer_html = (string) apply_filters('easy_invoice_email_footer_html', $footer_html, $this->settings); /** * Replace the whole email layout. * * Return a full HTML document to use it instead of the stock layout. * Pro's Email Enhancements addon uses this for a custom branded * layout; the placeholders it offers are resolved before this fires. * * @param string $html '' — return non-empty markup to take over. * @param string $message The email body (placeholders already replaced), unwrapped. * @param string $logo_html Logo block from Settings → Email, or ''. * @param string $footer_html Footer block, after the filter above. * @param array $settings Email settings. */ $custom = (string) apply_filters('easy_invoice_email_html', '', $message, $logo_html, $footer_html, $this->settings); if ('' !== trim($custom)) { return $custom; } return ' ' . esc_html($this->settings['from_name']) . ' '; } /** * Register email settings */ public function registerEmailSettings(): void { // Email settings section add_settings_section( 'easy_invoice_email_settings', __('Email Configuration', 'easy-invoice'), [$this, 'emailSettingsSectionCallback'], 'easy_invoice_settings' ); // Register settings $yes_no = static function ($value) { return in_array((string) $value, ['yes', '1', 'on', 'true'], true) ? 'yes' : 'no'; }; register_setting('easy_invoice_settings', 'easy_invoice_email_from_name', ['sanitize_callback' => 'sanitize_text_field']); register_setting('easy_invoice_settings', 'easy_invoice_email_from_address', ['sanitize_callback' => 'sanitize_email']); register_setting('easy_invoice_settings', 'easy_invoice_email_reply_to', ['sanitize_callback' => 'sanitize_email']); register_setting('easy_invoice_settings', 'easy_invoice_email_reply_to_name', ['sanitize_callback' => 'sanitize_text_field']); register_setting('easy_invoice_settings', 'easy_invoice_enable_email_styling', ['sanitize_callback' => $yes_no]); register_setting('easy_invoice_settings', 'easy_invoice_email_logo', ['sanitize_callback' => 'esc_url_raw']); register_setting('easy_invoice_settings', 'easy_invoice_email_footer_text', ['sanitize_callback' => 'wp_kses_post']); register_setting('easy_invoice_settings', 'easy_invoice_bcc_admin', ['sanitize_callback' => $yes_no]); register_setting('easy_invoice_settings', 'easy_invoice_admin_email', ['sanitize_callback' => 'sanitize_email']); // Add settings fields add_settings_field( 'easy_invoice_email_from_name', __('From Name', 'easy-invoice'), [$this, 'textFieldCallback'], 'easy_invoice_settings', 'easy_invoice_email_settings', ['label_for' => 'easy_invoice_email_from_name'] ); add_settings_field( 'easy_invoice_email_from_address', __('From Email Address', 'easy-invoice'), [$this, 'emailFieldCallback'], 'easy_invoice_settings', 'easy_invoice_email_settings', ['label_for' => 'easy_invoice_email_from_address'] ); add_settings_field( 'easy_invoice_email_reply_to', __('Reply-To Email', 'easy-invoice'), [$this, 'emailFieldCallback'], 'easy_invoice_settings', 'easy_invoice_email_settings', ['label_for' => 'easy_invoice_email_reply_to'] ); add_settings_field( 'easy_invoice_enable_email_styling', __('Enable HTML Emails', 'easy-invoice'), [$this, 'checkboxFieldCallback'], 'easy_invoice_settings', 'easy_invoice_email_settings', ['label_for' => 'easy_invoice_enable_email_styling'] ); add_settings_field( 'easy_invoice_bcc_admin', __('BCC Admin on All Emails', 'easy-invoice'), [$this, 'checkboxFieldCallback'], 'easy_invoice_settings', 'easy_invoice_email_settings', ['label_for' => 'easy_invoice_bcc_admin'] ); } /** * Email settings section callback */ public function emailSettingsSectionCallback(): void { echo '

' . esc_html__('Configure how emails are sent from Easy Invoice.', 'easy-invoice') . '

'; } /** * Text field callback * * @param array $args Field arguments */ public function textFieldCallback(array $args): void { $field_id = $args['label_for']; $value = get_option($field_id, ''); echo ''; } /** * Email field callback * * @param array $args Field arguments */ public function emailFieldCallback(array $args): void { $field_id = $args['label_for']; $value = get_option($field_id, ''); echo ''; } /** * Checkbox field callback * * @param array $args Field arguments */ public function checkboxFieldCallback(array $args): void { $field_id = $args['label_for']; $value = get_option($field_id, ''); echo ''; echo '' . esc_html__('Enable this option', 'easy-invoice') . ''; } /** * Log email sent * * @param Invoice $invoice The invoice * @param string $email The email address * @param string $type The email type */ public function logEmailSent($invoice, string $email, string $type): void { $this->log(sprintf('Email sent to %s for invoice #%s (%s)', $email, $invoice->getNumber(), $type), 'info'); } /** * Log email failed * * @param Invoice $invoice The invoice * @param string $email The email address * @param string $type The email type */ public function logEmailFailed($invoice, string $email, string $type): void { $this->log(sprintf('Email failed to %s for invoice #%s (%s)', $email, $invoice->getNumber(), $type), 'error'); } /** * Get default invoice template * * @return string Template */ /** * Replace the 2.3.x stock email bodies with the 2.4.0 ones — once, and * only where the saved body is still the stock text (compared by its * words, since the editor re-wraps markup on save). A body the site * edited is left alone. */ public static function refreshStockTemplates(): void { if ( get_option( 'easy_invoice_email_stock_v240' ) ) { return; } $old = [ 'invoice' => [ '83846ae6875ad4335976cc07140e56bc', 'b40d303fa4194c0da4257495fa9e138f' ], 'quote' => [ '7f31d11b8368fce31daddb7cbace9fb1', 'f9b9c4ad911ac729a04e52e35d056968' ], 'payment' => [ '52c9e647eac3d10ea39cf641e2bfc2b0' ], ]; foreach ( $old as $kind => $fingerprints ) { $key = 'easy_invoice_' . $kind . '_email_body'; $stored = get_option( $key, null ); if ( null === $stored || '' === $stored ) { continue; } $words = preg_replace( '/[^A-Za-z0-9{}]/u', '', html_entity_decode( wp_strip_all_tags( stripslashes( (string) $stored ) ) ) ); if ( in_array( md5( (string) $words ), $fingerprints, true ) ) { update_option( $key, self::defaultTemplate( $kind ) ); } } update_option( 'easy_invoice_email_stock_v240', 1, false ); } /** * The stock body for one of the emails, used wherever a default is needed * (settings screen, activation seeding, sending when nothing is saved). * * @param string $kind invoice | reminder | payment | quote | quote_accepted | quote_declined * @return string */ public static function defaultTemplate( string $kind ): string { switch ( $kind ) { case 'reminder': return self::getDefaultReminderTemplate(); case 'payment': return self::getDefaultPaymentTemplate(); case 'quote': return self::getDefaultQuoteTemplate(); case 'quote_accepted': return self::getDefaultQuoteAcceptedTemplate(); case 'quote_declined': return self::getDefaultQuoteDeclinedTemplate(); default: return self::getDefaultInvoiceTemplate(); } } private static function getDefaultInvoiceTemplate(): string { return '

Invoice {{invoice_number}}

Dear {{client_name}},

Please find invoice {{invoice_number}} for {{total_amount}}, due on {{due_date}}. The invoice is attached and can also be viewed and paid online:

View and pay invoice

{{invoice_url}}

Invoice number: {{invoice_number}}
Amount due: {{amount_due}}
Due date: {{due_date}}

If you have any questions about this invoice, just reply to this email.

Thank you for your business.

{{company_name}}
{{company_email}}

'; } private static function getDefaultReminderTemplate(): string { return '

Payment reminder — invoice {{invoice_number}}

Dear {{client_name}},

A reminder that invoice {{invoice_number}} was due on {{due_date}}; {{amount_due}} is still outstanding. If you have already paid, please disregard this message.

View and pay invoice

{{invoice_url}}

If you have a question about the invoice or need to arrange payment, reply to this email and we will sort it out.

Thank you.

{{company_name}}
{{company_email}}

'; } private static function getDefaultPaymentTemplate(): string { return '

Payment received — thank you

Dear {{client_name}},

We have received your payment of {{payment_amount}} against invoice {{invoice_number}}.

Invoice: {{invoice_number}}
Amount paid: {{payment_amount}}
Date: {{payment_date}}
Method: {{payment_method}}
Reference: {{transaction_id}}
Balance remaining: {{amount_due}}

Keep this email as your receipt. If you need anything else, reply to this message.

Thank you for your business.

{{company_name}}
{{company_email}}

'; } private static function getDefaultQuoteTemplate(): string { return '

Quote {{quote_number}}

Dear {{client_name}},

Please find our quote {{quote_number}} for {{total_amount}}, valid until {{expiry_date}}. It is attached, and you can review, accept or decline it online:

View quote

{{quote_url}}

Quote number: {{quote_number}}
Amount: {{total_amount}}
Valid until: {{expiry_date}}

If you would like to discuss any part of it, just reply to this email.

We look forward to working with you.

{{company_name}}
{{company_email}}

'; } private static function getDefaultQuoteAcceptedTemplate(): string { return '

Quote {{quote_number}} accepted

Dear {{client_name}},

Thank you for accepting quote {{quote_number}} for {{total_amount}} on {{acceptance_date}}.

We will send the invoice and any next steps shortly. If you have questions in the meantime, reply to this email.

Thank you for choosing us.

{{company_name}}
{{company_email}}

'; } private static function getDefaultQuoteDeclinedTemplate(): string { return '

Quote {{quote_number}}

Dear {{client_name}},

Thank you for letting us know that quote {{quote_number}} is not going ahead ({{response_date}}).

Reason given: {{decline_reason}}

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.

{{company_name}}
{{company_email}}

'; } /** * Refresh settings and templates * Call this method when settings are updated */ public function refreshSettings(): void { $this->loadSettings(); $this->loadTemplates(); } /** * Get email templates * * @return array Templates */ public function getTemplates(): array { return $this->templates; } /** * Get email settings * * @return array Settings */ public function getSettings(): array { return $this->settings; } /** * Test email functionality * * @param string $to_email Email to send test to * @return array Result */ public function testEmail(string $to_email): array { $subject = 'Easy Invoice - Email Configuration Test'; $message = '

🧪 Email Configuration Test

Hello!

✅ Test Email Successfully Sent
Date: ' . current_time('Y-m-d H:i:s') . '
To: ' . esc_html($to_email) . '

This is a test email to verify that your Easy Invoice email configuration is working correctly.

⚙️ Email Settings Verified:
• From Name: ' . esc_html($this->settings['from_name']) . '
• From Email: ' . esc_html($this->settings['from_email']) . '
• Reply-To: ' . esc_html($this->settings['reply_to_email'] ?: 'Not set') . '
• HTML Emails: ' . ($this->settings['enable_html'] === 'yes' ? 'Enabled' : 'Disabled') . '

🎉 Congratulations! If you received this email, your email configuration is working properly and you can now send invoices, quotes, and payment confirmations to your clients.

Thank you for using Easy Invoice!

Best regards,
' . esc_html($this->settings['from_name']) . '

'; if ($this->settings['enable_html'] === 'yes') { $message = $this->wrapInHtmlTemplate($message); } $headers = $this->prepareEmailHeaders(); $sent = $this->sendEmail($to_email, $subject, $message, $headers); if ($sent) { return ['success' => true, 'message' => __('Test email sent successfully', 'easy-invoice')]; } else { return ['success' => false, 'message' => __('Failed to send test email', 'easy-invoice')]; } } /** * Send test template email with custom subject and body * * @param string $to_email Email to send test to * @param string $subject Email subject * @param string $body Email body * @return array Result */ public function sendTestTemplateEmail(string $to_email, string $subject, string $body): array { if ($this->settings['enable_html'] === 'yes') { $body = $this->wrapInHtmlTemplate($body); } $headers = $this->prepareEmailHeaders(); $sent = $this->sendEmail($to_email, $subject, $body, $headers); if ($sent) { return ['success' => true, 'message' => __('Template test email sent successfully', 'easy-invoice')]; } else { return ['success' => false, 'message' => __('Failed to send template test email', 'easy-invoice')]; } } /** * Send payment received email * * @param Invoice $invoice The invoice * @param array $payment_data Payment data * @return array Result array with success status and message */ public function sendPaymentEmail(Invoice $invoice, array $payment_data = []): array { try { // Validate invoice if (!$invoice || !$invoice->getId()) { return ['success' => false, 'message' => __('Invalid invoice', 'easy-invoice')]; } // Get client email $client_email = $invoice->getCustomerEmail(); if (empty($client_email)) { return ['success' => false, 'message' => __('Client email is missing', 'easy-invoice')]; } // Get template $template_key = 'invoice_paid'; if (!isset($this->templates[$template_key])) { return ['success' => false, 'message' => __('Payment email template not found', 'easy-invoice')]; } $template = $this->templates[$template_key]; /** This filter is documented above in sendInvoiceEmail(). */ $template = (array) apply_filters('easy_invoice_email_template_data', $template, $template_key, $invoice); // Check if email is enabled if (!$template['enabled']) { return ['success' => false, 'message' => __('Payment received email is disabled', 'easy-invoice')]; } // Prepare email data $email_data = $this->preparePaymentEmailData($invoice, $template, $payment_data); // Send email $sent = $this->sendEmail( $email_data['to'], $email_data['subject'], $email_data['message'], $email_data['headers'] ); /** * Fires once an email send has finished, whether or not it went out. * * @param object $document Invoice or Quote model. * @param string $template_key Template key. * @param bool $sent Whether wp_mail() accepted it. */ do_action('easy_invoice_email_finished', $invoice, $template_key, (bool) $sent); if ($sent) { // Log success do_action('easy_invoice_payment_email_sent', $invoice, $client_email, $payment_data); return [ 'success' => true, 'message' => __('Payment email sent successfully', 'easy-invoice'), 'email_data' => $email_data ]; } else { // Log failure do_action('easy_invoice_payment_email_failed', $invoice, $client_email, $payment_data); return ['success' => false, 'message' => __('Failed to send payment email', 'easy-invoice')]; } } catch (\Exception $e) { $this->log('Payment email sending error: ' . $e->getMessage(), 'error'); return ['success' => false, 'message' => __('Error sending payment email: ', 'easy-invoice') . $e->getMessage()]; } } /** * Send admin notification when payment is received * * @param Invoice $invoice The invoice * @param array $payment_data Payment data (method, amount, etc.) * @return array Result array with success status and message */ public function sendAdminPaymentNotification(Invoice $invoice, array $payment_data = []): array { try { // Validate invoice if (!$invoice || !$invoice->getId()) { return ['success' => false, 'message' => __('Invalid invoice', 'easy-invoice')]; } // Get admin email $admin_email = $this->settings['admin_email'] ?? get_option('admin_email'); if (empty($admin_email)) { return ['success' => false, 'message' => __('Admin email is missing', 'easy-invoice')]; } // Get payment method $payment_method = $payment_data['payment_method'] ?? $payment_data['method'] ?? 'online'; $payment_method_label = $this->getPaymentMethodLabel($payment_method); // Format amount $formatter = new \EasyInvoice\Helpers\InvoiceFormatter($invoice); // The payment that came in, not the invoice's face value. $amount = $formatter->format(isset($payment_data['amount']) && (float) $payment_data['amount'] > 0 ? (float) $payment_data['amount'] : $invoice->getTotal()); // Prepare email subject $pending = !empty($payment_data['pending']); $subject = sprintf( /* translators: %s: document number. */ $pending ? __('Payment awaiting verification - Invoice #%s', 'easy-invoice') : __('New Payment Received - Invoice #%s', 'easy-invoice'), $invoice->getNumber() ); // Prepare email message $message = $this->prepareAdminPaymentNotificationMessage($invoice, $payment_method_label, $amount, $payment_data); // Add HTML wrapper if enabled if ($this->settings['enable_html'] === 'yes') { $message = $this->wrapInHtmlTemplate($message); } // Prepare headers $headers = $this->prepareEmailHeaders(); // Send email $sent = $this->sendEmail($admin_email, $subject, $message, $headers); if ($sent) { do_action('easy_invoice_admin_payment_notification_sent', $invoice, $admin_email, $payment_data); return [ 'success' => true, 'message' => __('Admin notification sent successfully', 'easy-invoice') ]; } else { do_action('easy_invoice_admin_payment_notification_failed', $invoice, $admin_email, $payment_data); return ['success' => false, 'message' => __('Failed to send admin notification', 'easy-invoice')]; } } catch (\Exception $e) { $this->log('Admin payment notification error: ' . $e->getMessage(), 'error'); return ['success' => false, 'message' => __('Error sending admin notification: ', 'easy-invoice') . $e->getMessage()]; } } /** * Tell the admin a manual payment is waiting for verification. * * @param int $invoice_id The invoice paid. * @param string $payment_method Gateway or payment type submitted. */ public function handleManualPaymentSubmitted($invoice_id, $payment_method = 'manual', $payment_id = 0): void { $post = get_post((int) $invoice_id); if (!$post) { return; } $invoice = new Invoice($post); if (!$invoice->getId()) { return; } $payment_id = (int) $payment_id; $payment_data = [ 'payment_method' => (string) $payment_method, 'pending' => true, 'payment_id' => $payment_id, ]; if ($payment_id) { $payment_data['amount'] = (float) get_post_meta($payment_id, '_amount', true); $payment_data['notes'] = (string) get_post_meta($payment_id, '_notes', true); $payment_data['reference'] = (string) get_post_meta($payment_id, \EasyInvoice\Services\OfflinePayments::META_REFERENCE, true); if (\EasyInvoice\Services\OfflinePayments::hasProof($payment_id)) { $payment_data['proof_url'] = \EasyInvoice\Services\OfflinePayments::proofUrl($payment_id); } } else { $notes = get_post_meta($invoice->getId(), '_manual_payment_notes', true); if ($notes) { $payment_data['notes'] = $notes; } } $this->sendAdminPaymentNotification($invoice, $payment_data); } /** * Send payment confirmation email to customer * * @param Invoice $invoice The invoice * @param array $payment_data Payment data * @return array Result array with success status and message */ public function sendPaymentConfirmationEmail(Invoice $invoice, array $payment_data = []): array { try { // Validate invoice if (!$invoice || !$invoice->getId()) { return ['success' => false, 'message' => __('Invalid invoice', 'easy-invoice')]; } // Get customer email $customer_email = $invoice->getCustomerEmail(); if (empty($customer_email)) { return ['success' => false, 'message' => __('Customer email is missing', 'easy-invoice')]; } // Get currency settings $settings_controller = new \EasyInvoice\Controllers\SettingsController(); $settings = $settings_controller->getSettings(); $currency_code = $settings['easy_invoice_currency_code'] ?? 'USD'; $currency_symbol = \EasyInvoice\Helpers\CurrencyHelper::getCurrencySymbol($currency_code); // Format amount $amount = $invoice->getTotal(); $formatted_amount = $currency_symbol . number_format($amount, 2); // Prepare email subject $site_name = get_bloginfo('name'); $subject = sprintf( /* translators: %1$s: site name; %2$s: document number. */ __('[%1$s] Payment Confirmed - Invoice #%2$s', 'easy-invoice'), $site_name, $invoice->getNumber() ); // Prepare email message $message = $this->preparePaymentConfirmationMessage($invoice, $formatted_amount); // Add HTML wrapper if enabled if ($this->settings['enable_html'] === 'yes') { $message = $this->wrapInHtmlTemplate($message); } // Prepare headers $headers = $this->prepareEmailHeaders(); // Add BCC to admin if enabled (but skip if this is from payment completion hook to avoid duplicate) // The payment completion hook already sends a dedicated admin notification $skip_bcc = isset($payment_data['skip_bcc']) && $payment_data['skip_bcc'] === true; if (!$skip_bcc && $this->settings['bcc_admin'] === 'yes' && !empty($this->settings['admin_email'])) { $headers[] = 'Bcc: ' . $this->settings['admin_email']; } // Send email $sent = $this->sendEmail($customer_email, $subject, $message, $headers); if ($sent) { do_action('easy_invoice_payment_confirmation_sent', $invoice, $customer_email, $payment_data); return [ 'success' => true, 'message' => __('Payment confirmation email sent successfully', 'easy-invoice') ]; } else { do_action('easy_invoice_payment_confirmation_failed', $invoice, $customer_email, $payment_data); return ['success' => false, 'message' => __('Failed to send payment confirmation email', 'easy-invoice')]; } } catch (\Exception $e) { $this->log('Payment confirmation email error: ' . $e->getMessage(), 'error'); return ['success' => false, 'message' => __('Error sending payment confirmation: ', 'easy-invoice') . $e->getMessage()]; } } /** * Send payment rejection email to customer * * @param Invoice $invoice The invoice * @param string $reason Rejection reason * @return array Result array with success status and message */ public function sendPaymentRejectionEmail(Invoice $invoice, string $reason = ''): array { try { // Validate invoice if (!$invoice || !$invoice->getId()) { return ['success' => false, 'message' => __('Invalid invoice', 'easy-invoice')]; } // Get customer email $customer_email = $invoice->getCustomerEmail(); if (empty($customer_email)) { return ['success' => false, 'message' => __('Customer email is missing', 'easy-invoice')]; } // Prepare email subject $site_name = get_bloginfo('name'); $subject = sprintf( /* translators: %1$s: site name; %2$s: document number. */ __('[%1$s] Payment Rejected - Invoice #%2$s', 'easy-invoice'), $site_name, $invoice->getNumber() ); // Prepare email message $message = $this->preparePaymentRejectionMessage($invoice, $reason); // Add HTML wrapper if enabled if ($this->settings['enable_html'] === 'yes') { $message = $this->wrapInHtmlTemplate($message); } // Prepare headers $headers = $this->prepareEmailHeaders(); // Add BCC to admin if enabled if ($this->settings['bcc_admin'] === 'yes' && !empty($this->settings['admin_email'])) { $headers[] = 'Bcc: ' . $this->settings['admin_email']; } // Send email $sent = $this->sendEmail($customer_email, $subject, $message, $headers); if ($sent) { do_action('easy_invoice_payment_rejection_sent', $invoice, $customer_email, $reason); return [ 'success' => true, 'message' => __('Payment rejection email sent successfully', 'easy-invoice') ]; } else { do_action('easy_invoice_payment_rejection_failed', $invoice, $customer_email, $reason); return ['success' => false, 'message' => __('Failed to send payment rejection email', 'easy-invoice')]; } } catch (\Exception $e) { $this->log('Payment rejection email error: ' . $e->getMessage(), 'error'); return ['success' => false, 'message' => __('Error sending payment rejection: ', 'easy-invoice') . $e->getMessage()]; } } /** * Prepare admin payment notification message * * @param Invoice $invoice The invoice * @param string $payment_method_label Payment method label * @param string $amount Formatted amount * @param array $payment_data Payment data * @return string Email message */ private function prepareAdminPaymentNotificationMessage(Invoice $invoice, string $payment_method_label, string $amount, array $payment_data = []): string { $invoice_number = $invoice->getNumber(); $customer_name = $invoice->getCustomerName(); $customer_email = $invoice->getCustomerEmail(); $invoice_id = $invoice->getId(); $pending = !empty($payment_data['pending']); $message = sprintf( /* translators: %1$s: payment method; %2$s: invoice number. */ $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'), $payment_method_label, $invoice_number ); $message .= "\n\n"; /* translators: . */ $message .= __('Invoice Details:', 'easy-invoice'); $message .= "\n"; /* translators: %s: amount. */ $message .= sprintf($pending ? __('- Amount submitted: %s', 'easy-invoice') : __('- Amount received: %s', 'easy-invoice'), $amount); $message .= "\n"; $ei_due = \EasyInvoice\Services\InvoiceBalance::due($invoice); /* translators: %s: amount. */ $message .= sprintf(__('- Still owed: %s', 'easy-invoice'), (new \EasyInvoice\Helpers\InvoiceFormatter($invoice))->format($ei_due)); $message .= "\n"; /* translators: %s: customer name. */ $message .= sprintf(__('- Customer: %s', 'easy-invoice'), $customer_name); $message .= "\n"; /* translators: %s: customer email address. */ $message .= sprintf(__('- Email: %s', 'easy-invoice'), $customer_email); if (!empty($payment_data['notes'])) { $message .= "\n"; /* translators: %s: note left by the client. */ $message .= sprintf(__('- Client note: %s', 'easy-invoice'), $payment_data['notes']); } // Add transaction ID if available if (!empty($payment_data['transaction_id'])) { $message .= "\n"; /* translators: %s: transaction id. */ $message .= sprintf(__('- Transaction ID: %s', 'easy-invoice'), $payment_data['transaction_id']); } if (!empty($payment_data['reference'])) { $message .= "\n"; /* translators: %s: reference the client typed. */ $message .= sprintf(__('- Reference given by the client: %s', 'easy-invoice'), $payment_data['reference']); } if (!empty($payment_data['proof_url'])) { $message .= "\n"; /* translators: %s: link. */ $message .= sprintf(__('- Receipt attached: %s', 'easy-invoice'), $payment_data['proof_url']); } elseif ($pending) { $message .= "\n"; $message .= __('- No receipt was attached.', 'easy-invoice'); } $message .= "\n\n"; $message .= $pending ? __('Confirm or reject it on the Payments screen:', 'easy-invoice') : __('Please review this payment in the admin dashboard:', 'easy-invoice'); $message .= "\n"; $message .= !empty($payment_data['payment_id']) ? admin_url('admin.php?page=easy-invoice-payments&action=view&id=' . (int) $payment_data['payment_id']) : admin_url('admin.php?page=easy-invoice-payments&status=pending'); $message .= "\n\n"; $message .= __('This is an automated message from Easy Invoice.', 'easy-invoice'); return $message; } /** * Prepare payment confirmation message * * @param Invoice $invoice The invoice * @param string $formatted_amount Formatted amount * @return string Email message */ private function preparePaymentConfirmationMessage(Invoice $invoice, string $formatted_amount): string { $customer_name = $invoice->getCustomerName(); $invoice_number = $invoice->getNumber(); $site_name = get_bloginfo('name'); $company_name = get_option('easy_invoice_company_name', $site_name); /* translators: %s: customer name. */ /* translators: %s: customer name. */ $message = sprintf(__('Dear %s,', 'easy-invoice'), $customer_name); $message .= "\n\n"; $message .= sprintf( /* translators: %1$s: amount paid; %2$s: invoice number. */ __('We are pleased to confirm that your payment of %1$s for Invoice #%2$s has been received and processed successfully.', 'easy-invoice'), $formatted_amount, $invoice_number ); $message .= "\n\n"; $message .= __('Thank you for your business.', 'easy-invoice'); $message .= "\n\n"; $message .= __('Regards,', 'easy-invoice'); $message .= "\n"; $message .= $company_name; return $message; } /** * Prepare payment rejection message * * @param Invoice $invoice The invoice * @param string $reason Rejection reason * @return string Email message */ private function preparePaymentRejectionMessage(Invoice $invoice, string $reason = ''): string { $customer_name = $invoice->getCustomerName(); $invoice_number = $invoice->getNumber(); $site_name = get_bloginfo('name'); $company_name = get_option('easy_invoice_company_name', $site_name); /* translators: %s: customer name. */ /* translators: %s: customer name. */ $message = sprintf(__('Dear %s,', 'easy-invoice'), $customer_name); $message .= "\n\n"; $message .= sprintf( /* translators: %s: invoice number. */ __('We regret to inform you that your payment for Invoice #%s has been rejected.', 'easy-invoice'), $invoice_number ); if (!empty($reason)) { $message .= "\n\n"; $message .= __('Reason:', 'easy-invoice'); $message .= "\n"; $message .= $reason; } $message .= "\n\n"; $message .= __('Please contact us if you have any questions or concerns.', 'easy-invoice'); $message .= "\n\n"; $message .= __('Regards,', 'easy-invoice'); $message .= "\n"; $message .= $company_name; return $message; } /** * Send admin notification for quote acceptance/decline * * @param Quote $quote The quote * @param string $action Action type ('accepted' or 'declined') * @return array Result array with success status and message */ public function sendAdminQuoteNotification(Quote $quote, string $action = 'accepted'): array { try { // Validate quote if (!$quote || !$quote->getId()) { return ['success' => false, 'message' => __('Invalid quote', 'easy-invoice')]; } // Get admin email $admin_email = $this->settings['admin_email'] ?? get_option('admin_email'); if (empty($admin_email)) { return ['success' => false, 'message' => __('Admin email is missing', 'easy-invoice')]; } // Prepare email subject $subject = sprintf( /* translators: %1$s: document number; %2$s: value. */ __('Quote %1$s has been %2$s', 'easy-invoice'), $quote->getNumber(), $action === 'accepted' ? __('accepted', 'easy-invoice') : __('declined', 'easy-invoice') ); // Prepare email message $message = $this->prepareAdminQuoteNotificationMessage($quote, $action); // Add HTML wrapper if enabled if ($this->settings['enable_html'] === 'yes') { $message = $this->wrapInHtmlTemplate($message); } // Prepare headers $headers = $this->prepareEmailHeaders(); // Send email $sent = $this->sendEmail($admin_email, $subject, $message, $headers); if ($sent) { do_action('easy_invoice_admin_quote_notification_sent', $quote, $admin_email, $action); return [ 'success' => true, 'message' => __('Admin notification sent successfully', 'easy-invoice') ]; } else { do_action('easy_invoice_admin_quote_notification_failed', $quote, $admin_email, $action); return ['success' => false, 'message' => __('Failed to send admin notification', 'easy-invoice')]; } } catch (\Exception $e) { $this->log('Admin quote notification error: ' . $e->getMessage(), 'error'); return ['success' => false, 'message' => __('Error sending admin notification: ', 'easy-invoice') . $e->getMessage()]; } } /** * Prepare admin quote notification message * * @param Quote $quote The quote * @param string $action Action type ('accepted' or 'declined') * @return string Email message */ private function prepareAdminQuoteNotificationMessage(Quote $quote, string $action): string { $site_name = get_bloginfo('name'); $quote_number = $quote->getNumber(); $customer_name = $quote->getCustomerName(); // Format amount $formatter = new \EasyInvoice\Helpers\InvoiceFormatter($quote); $formatted_amount = $formatter->format($quote->getTotal()); $action_label = $action === 'accepted' ? __('accepted', 'easy-invoice') : __('declined', 'easy-invoice'); $date_label = $action === 'accepted' ? __('Accepted Date', 'easy-invoice') : __('Declined Date', 'easy-invoice'); /* translators: . */ $message = __('Hello,', 'easy-invoice'); $message .= "\n\n"; $message .= sprintf( /* translators: %1$s: quote number; %2$s: quote title; %3$s: accepted or declined. */ __('The quote %1$s for %2$s has been %3$s by the client.', 'easy-invoice'), $quote_number, $customer_name, $action_label ); $message .= "\n\n"; /* translators: . */ $message .= __('Quote Details:', 'easy-invoice'); $message .= "\n"; /* translators: %s: quote number. */ $message .= sprintf(__('- Quote Number: %s', 'easy-invoice'), $quote_number); $message .= "\n"; /* translators: %s: customer name. */ $message .= sprintf(__('- Client: %s', 'easy-invoice'), $customer_name); $message .= "\n"; /* translators: %s: amount. */ $message .= sprintf(__('- Total Amount: %s', 'easy-invoice'), $formatted_amount); $message .= "\n"; /* translators: %1$s: label such as "Accepted on"; %2$s: date and time. */ $message .= sprintf(__('- %1$s: %2$s', 'easy-invoice'), $date_label, date_i18n(get_option('date_format') . ' ' . get_option('time_format'))); $message .= "\n\n"; $message .= __('You can view the quote at:', 'easy-invoice'); $message .= "\n"; $message .= get_permalink($quote->getId()); $message .= "\n\n"; $message .= __('Best regards,', 'easy-invoice'); $message .= "\n"; $message .= $site_name; return $message; } /** * Handle payment completed hook * Sends admin notification and customer confirmation when payment is completed * * @param int $invoice_id Invoice ID * @param \EasyInvoice\Models\Invoice $invoice Invoice object * @param array $payment_data Payment data (method, gateway, transaction_id, amount) * @return void */ public function handlePaymentCompleted(int $invoice_id, $invoice, array $payment_data = []): void { if (!$invoice || !$invoice->getId()) { return; } // Send admin notification $this->sendAdminPaymentNotification($invoice, $payment_data); // Send customer confirmation email using proper template system // Check if payment email is enabled first if (isset($this->templates['invoice_paid']) && $this->templates['invoice_paid']['enabled']) { $this->sendInvoiceEmail($invoice, 'paid', array_merge($payment_data, ['skip_bcc' => true, 'payment_receipt' => true])); } } /** * Get payment method label * * @param string $method Payment method * @return string Payment method label */ private function getPaymentMethodLabel(string $method): string { if (class_exists('\\EasyInvoice\\Services\\OfflinePayments') && \EasyInvoice\Services\OfflinePayments::isOffline($method)) { return \EasyInvoice\Services\OfflinePayments::label($method); } $labels = [ 'bank' => __('Bank Transfer', 'easy-invoice'), 'bank_transfer' => __('Bank Transfer', 'easy-invoice'), 'cash' => __('Cash', 'easy-invoice'), 'check' => __('Cheque', 'easy-invoice'), 'cheque' => __('Cheque', 'easy-invoice'), 'paystack' => __('Paystack', 'easy-invoice'), 'moneris' => __('Moneris', 'easy-invoice'), 'other' => __('Other', 'easy-invoice'), 'paypal' => __('PayPal', 'easy-invoice'), 'stripe' => __('Stripe', 'easy-invoice'), 'square' => __('Square', 'easy-invoice'), 'mollie' => __('Mollie', 'easy-invoice'), 'authorizenet' => __('Authorize.Net', 'easy-invoice'), 'manual' => __('Manual Payment', 'easy-invoice'), 'online' => __('Online Payment', 'easy-invoice'), ]; return $labels[$method] ?? ucfirst($method); } }