Files
portal-api/app/Services/InvoiceReminderService.php

128 lines
5.0 KiB
PHP

<?php
namespace App\Services;
use App\Models\Customer;
use App\Models\Invoice;
use App\Services\Email\EmailTemplateService;
use App\Support\AutoEmailGate;
use App\Support\EmailTemplateShell;
use App\Support\InvoiceLookup;
use Carbon\Carbon;
/**
* Nhắc thanh toán hoá đơn. Cột invoices.reminder_step có từ migration đầu tiên nhưng chưa
* từng được đọc/ghi — khách chỉ biết mình nợ khi dịch vụ đã bị tắt. Ở đây reminder_step là
* SỐ MỐC NHẮC ĐÃ GỬI, nên chạy lại command trong ngày không gửi trùng.
*/
class InvoiceReminderService
{
public function __construct(
private readonly EmailTemplateService $templates,
) {}
public const SCENARIO = 'invoice_payment_reminder';
/** Hoá đơn còn phải thu thì mới nhắc. */
public function isRemindable(Invoice $invoice): bool
{
return in_array((int) $invoice->status, InvoicePaymentService::PAYABLE_STATUSES, true);
}
/**
* Số mốc nhắc đã đến hạn tính tới hôm nay, theo config('billing.reminder_offsets')
* (số âm = trước hạn, dương = sau hạn).
*/
public function dueStep(Invoice $invoice, ?Carbon $now = null): int
{
if (! $invoice->due_date) {
return 0;
}
$now = $now ?? now();
$daysFromDue = (int) $invoice->due_date->copy()->startOfDay()->diffInDays($now->copy()->startOfDay(), false);
$offsets = (array) config('billing.reminder_offsets', []);
if ($offsets === []) {
return 0;
}
sort($offsets);
// Quá mốc nhắc cuối cùng (cộng ân hạn) thì chuỗi nhắc đã đóng — không mở lại.
//
// Không có guard này, một lần import dữ liệu cũ (WHMCS) sẽ khiến mọi hoá đơn chưa
// thanh toán từ nhiều năm trước có reminder_step = 0 và due_date quá hạn rất lâu,
// cron 08:00 hôm sau bắn email nhắc nợ cho toàn bộ khách hàng cũ cùng lúc.
if ($daysFromDue > self::reminderCutoff($offsets)) {
return 0;
}
return count(array_filter($offsets, fn ($offset) => $daysFromDue >= $offset));
}
/** Ngày quá hạn tối đa còn nằm trong chuỗi nhắc. */
public static function reminderCutoff(array $offsets): int
{
return (int) max($offsets) + (int) config('billing.reminder_grace_days', 7);
}
/**
* Gửi nhắc nếu có mốc mới chưa gửi. Trả về true khi thực sự gửi.
* $force dùng cho nút "Nhắc thanh toán" thủ công của admin — bỏ qua việc đã gửi mốc đó rồi.
*/
public function remind(Invoice $invoice, bool $force = false, ?Carbon $now = null): bool
{
if (! $this->isRemindable($invoice)) {
return false;
}
$dueStep = $this->dueStep($invoice, $now);
if (! $force && $dueStep <= (int) $invoice->reminder_step) {
return false;
}
if (! $force && AutoEmailGate::blocks(self::SCENARIO)) {
return false;
}
$customer = $invoice->customer;
if (! $customer?->email) {
return false;
}
$this->templates->send(self::SCENARIO, $customer->email, $this->vars($invoice, $customer, $now));
$invoice->update(['reminder_step' => max($dueStep, (int) $invoice->reminder_step + ($force ? 1 : 0))]);
return true;
}
private function vars(Invoice $invoice, Customer $customer, ?Carbon $now): array
{
$now = $now ?? now();
$dueDate = $invoice->due_date;
$daysLate = $dueDate ? (int) $dueDate->copy()->startOfDay()->diffInDays($now->copy()->startOfDay(), false) : 0;
$outstanding = max(0, (int) $invoice->total - (int) ($invoice->amount_paid ?? 0));
return [
'client_name' => $customer->fullname ?? '',
'invoice_number' => $invoice->invoice_number,
'total' => number_format((int) $invoice->total, 0, ',', '.'),
'outstanding' => number_format($outstanding, 0, ',', '.'),
'due_date' => $dueDate?->format('d/m/Y') ?? '-',
'status_line' => $daysLate > 0
? 'Hoá đơn đã quá hạn ' . $daysLate . ' ngày.'
: ($daysLate === 0 ? 'Hôm nay là hạn thanh toán của hoá đơn này.' : 'Hoá đơn sẽ đến hạn sau ' . abs($daysLate) . ' ngày.'),
'paid_row' => (int) ($invoice->amount_paid ?? 0) > 0
? EmailTemplateShell::row('Đã thanh toán', number_format((int) $invoice->amount_paid, 0, ',', '.') . ' VNĐ')
: '',
'lookup_row' => InvoiceLookup::emailRow($invoice),
'lookup_note' => InvoiceLookup::emailNote($invoice),
'login_url' => rtrim((string) config('app.frontend_url', ''), '/') . '/dang-nhap',
'support_email' => 'support@tgs.com.vn',
'support_phone' => '(028) 7309. 7379',
];
}
}