Files
portal-api/app/Mail/InvoiceNotificationMail.php
T
baodev0 0b08689ea4 feat: harden admin auth and ticket workflows
- add rotating admin refresh tokens and password gates\n- expand ticket galleries, exports, and client resources\n- improve IP allocation auditing and dashboard data\n- enforce encrypted client access tokens on public routes
2026-10-16 17:00:55 +07:00

172 lines
7.1 KiB
PHP

<?php
namespace App\Mail;
use App\Enums\Invoice\InvoiceStatus;
use App\Models\Invoice;
use App\Services\InvoicePaymentService;
use App\Support\MailGlobals;
use App\Support\EmailTemplateShell;
use Carbon\Carbon;
use Illuminate\Mail\Mailables\Attachment;
/**
* KHÔNG implement ShouldQueue — gọi từ trong queued job, gửi đồng bộ.
*
* Render từ tbl.template (scenario `invoice_notification`) như mọi mailable khác. Trước đây
* đi qua bảng email_notification_templates riêng và rơi về blade khi không có template —
* hai hệ template song song cho cùng một email, admin sửa một nơi thì nơi kia vẫn nguyên.
*/
class InvoiceNotificationMail extends TemplateMailable
{
public const SCENARIO = 'invoice_notification';
public function __construct(
public readonly Invoice $invoice,
public readonly string $customerName,
public readonly string $message,
public readonly string $companyName,
public readonly string $type = 'invoice',
public readonly ?string $invoicePdf = null,
public readonly ?string $invoicePdfFileName = null,
public readonly ?string $paymentUrl = null,
) {}
public static function forInvoice(
Invoice $invoice,
string $type = 'invoice',
?string $message = null,
?string $invoicePdf = null,
?string $invoicePdfFileName = null,
): self {
$message ??= $type === 'renewal'
? "Dịch vụ của quý khách sắp đến hạn gia hạn. Vui lòng kiểm tra hóa đơn #{$invoice->order_num} được đính kèm và hoàn tất thanh toán để dịch vụ không bị gián đoạn."
: "Vui lòng xem thông tin hóa đơn #{$invoice->order_num} của bạn.";
return new self(
$invoice,
$invoice->customer->fullname,
$message,
setting('general.company_name') ?: config('app.name'),
$type,
$invoicePdf,
$invoicePdfFileName,
MailGlobals::frontendUrl() . $invoice->clientPath(),
);
}
public function attachments(): array
{
if ($this->invoicePdf !== null && $this->invoicePdfFileName !== null) {
return [
Attachment::fromData(fn() => $this->invoicePdf, $this->invoicePdfFileName)
->withMime('application/pdf'),
];
}
return [];
}
protected function scenario(): string
{
return self::SCENARIO;
}
protected function variables(): array
{
$isRenewal = $this->type === 'renewal';
return [
'title' => $isRenewal ? 'Thông báo gia hạn cước' : 'Thông báo hóa đơn',
'company_name' => $this->companyName,
'customer_name' => $this->customerName,
'body_message' => $this->message,
'invoice_number' => $this->invoice->invoice_number ?? $this->invoice->order?->order_num ?? '',
'created_date' => $this->formatDate($this->invoice->date ?? $this->invoice->created_at),
'due_date' => $this->formatDate($this->invoice->due_date),
'total' => number_format((int) $this->invoice->total, 0, ',', '.'),
'status_label' => $this->statusLabel(),
'outstanding_row' => $this->outstandingRow(),
'intro_note' => $isRenewal
? 'File PDF hóa đơn gia hạn đã được đính kèm trong email này. Vui lòng hoàn tất thanh toán trước hạn để dịch vụ không bị gián đoạn.'
: 'File PDF hóa đơn đã được đính kèm trong email này để Quý khách tiện lưu trữ và đối chiếu.',
'pay_button' => $this->payButton(),
'attachments_block' => '',
'note_block' => $this->noteBlock(),
];
}
private function formatDate(mixed $value): string
{
if (empty($value)) {
return '—';
}
if ($value instanceof \Carbon\CarbonInterface) {
return $value->format('d/m/Y');
}
if (is_numeric($value)) {
return date('d/m/Y', (int) $value);
}
try {
return Carbon::parse($value)->format('d/m/Y');
} catch (\Throwable) {
return '—';
}
}
private function statusLabel(): string
{
return match ((int) $this->invoice->status) {
InvoiceStatus::PAID->value => '<strong style="color: #059669;">Đã thanh toán</strong>',
InvoiceStatus::UNPAID->value => '<strong style="color: #dc2626;">Chưa thanh toán</strong>',
InvoiceStatus::OVERDUE->value => '<strong style="color: #b45309;">Quá hạn</strong>',
InvoiceStatus::REFUNDED->value => '<strong style="color: #7c3aed;">Đã hoàn tiền</strong>',
default => '<strong style="color: #9ca3af;">Đã hủy</strong>',
};
}
/** Chỉ hiện khi khách đã trả được một phần nhưng chưa đủ. */
private function outstandingRow(): string
{
$paid = (int) $this->invoice->amount_paid;
$outstanding = InvoicePaymentService::outstanding($this->invoice);
if ($paid <= 0 || $outstanding <= 0) {
return '';
}
return EmailTemplateShell::row('Đã thanh toán', '<span style="color: #059669;">' . number_format($paid, 0, ',', '.') . ' VNĐ</span>')
. EmailTemplateShell::row('Còn thiếu', '<span style="color: #dc2626;">' . number_format($outstanding, 0, ',', '.') . ' VNĐ</span>');
}
private function payButton(): string
{
if (empty($this->paymentUrl)) {
return '';
}
$url = htmlspecialchars($this->paymentUrl, ENT_QUOTES, 'UTF-8');
return '<table cellspacing="0" cellpadding="0" style="margin: 24px auto 8px auto;"><tbody><tr><td align="center" style="background-color: #f16723; padding: 12px 24px; border-radius: 4px;">'
. '<a href="' . $url . '" target="_blank" rel="noopener" style="text-decoration: none; font-size: 14px; font-weight: 600; color: #ffffff;">THANH TOÁN HÓA ĐƠN</a>'
. '</td></tr></tbody></table>'
. '<p style="margin: 0 0 18px 0; word-break: break-all; font-size: 13px; color: #6b7280;">Hoặc sao chép liên kết thanh toán: <a href="' . $url . '" style="color: #f16723;">' . $url . '</a></p>';
}
private function noteBlock(): string
{
if (empty($this->invoice->note)) {
return '';
}
return '<table style="width: 100%; border-collapse: collapse; margin-top: 16px;" role="presentation" cellspacing="0" cellpadding="0"><tbody><tr>'
. '<td style="padding: 14px 16px; border-left: 3px solid #f16723; background-color: #f8fafc; font-size: 13px; line-height: 20px; color: #1a1a1a;">'
. '<strong style="color: #374151;">Ghi chú:</strong> '
. htmlspecialchars((string) $this->invoice->note, ENT_QUOTES, 'UTF-8')
. '</td></tr></tbody></table>';
}
}