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; } $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 ? '