This commit is contained in:
baodev0
2026-09-23 15:17:18 +07:00
parent cd38c5d6d0
commit 82a71244ca
107 changed files with 3332 additions and 3306 deletions
+10 -21
View File
@@ -3,7 +3,6 @@
namespace App\Console\Commands;
use App\Enums\Invoice\InvoiceStatus;
use App\Enums\Order\OrderStatus;
use App\Models\Invoice;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\DB;
@@ -12,7 +11,7 @@ use Illuminate\Support\Facades\Log;
class CheckOverdueInvoices extends Command
{
protected $signature = 'invoices:check-overdue {--dry-run : Preview invoices without updating data}';
protected $description = 'Mark unpaid invoices past due date as overdue and keep related orders unpaid.';
protected $description = 'Mark unpaid invoices past due date as overdue.';
public function handle(): int
{
@@ -36,8 +35,6 @@ class CheckOverdueInvoices extends Command
}
$invoices = Invoice::query()
// customer_id must be selected too: OrderObserver::updated() reads it when
// logging the status change below, and a NULL there violates log.customer_id NOT NULL.
->with('order:id,status,customer_id')
->whereNotNull('due_date')
->whereDate('due_date', '<', $today)
@@ -47,36 +44,28 @@ class CheckOverdueInvoices extends Command
$this->info("invoices:check-overdue | Found {$invoices->count()} invoice(s) due before {$today}.");
$updatedInvoices = 0;
$updatedOrders = 0;
foreach ($invoices as $invoice) {
$needsInvoiceUpdate = (int) $invoice->status !== InvoiceStatus::OVERDUE->value;
$order = $invoice->order;
$needsOrderUpdate = $order
&& ! in_array((int) $order->status, [OrderStatus::PENDING->value, OrderStatus::CANCELLED->value], true);
$this->line(sprintf(
' [%s] Invoice #%s due %s%s',
$dryRun ? 'dry-run' : 'check',
$invoice->invoice_number,
$invoice->due_date?->format('Y-m-d') ?? '-',
$order ? " | order #{$invoice->order_id}" : ''
$invoice->order_id ? " | order #{$invoice->order_id}" : ''
));
if ($dryRun || (! $needsInvoiceUpdate && ! $needsOrderUpdate)) {
if ($dryRun || ! $needsInvoiceUpdate) {
continue;
}
DB::transaction(function () use ($invoice, $order, $needsInvoiceUpdate, $needsOrderUpdate, &$updatedInvoices, &$updatedOrders) {
if ($needsInvoiceUpdate) {
$invoice->update(['status' => InvoiceStatus::OVERDUE->value]);
$updatedInvoices++;
}
if ($needsOrderUpdate && $order) {
$order->update(['status' => OrderStatus::PENDING->value]);
$updatedOrders++;
}
// Chỉ đổi trạng thái hoá đơn. Trước đây command còn đẩy order về PENDING, nhưng hoá đơn
// gia hạn dùng lại order_id gốc nên một kỳ gia hạn quá hạn sẽ kéo đơn đang chạy về
// "Chờ xử lý" — vừa hiển thị sai, vừa khiến hasPendingOrder() khoá khách đặt đơn mới.
DB::transaction(function () use ($invoice, &$updatedInvoices) {
$invoice->update(['status' => InvoiceStatus::OVERDUE->value]);
$updatedInvoices++;
});
Log::info('Invoice marked overdue', [
@@ -86,7 +75,7 @@ class CheckOverdueInvoices extends Command
]);
}
$this->info("invoices:check-overdue | Updated {$updatedInvoices} invoice(s), {$updatedOrders} order(s).");
$this->info("invoices:check-overdue | Updated {$updatedInvoices} invoice(s).");
return Command::SUCCESS;
}
@@ -0,0 +1,79 @@
<?php
namespace App\Console\Commands;
use App\Models\Invoice;
use App\Services\InvoicePaymentService;
use App\Services\InvoiceReminderService;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\Log;
class SendInvoiceReminders extends Command
{
protected $signature = 'invoices:send-reminders {--dry-run : Preview which invoices would be reminded}';
protected $description = 'Send payment reminders for invoices approaching or past their due date.';
public function handle(InvoiceReminderService $reminders): int
{
$dryRun = (bool) $this->option('dry-run');
$offsets = (array) config('billing.reminder_offsets', []);
if ($offsets === []) {
$this->warn('invoices:send-reminders | billing.reminder_offsets rỗng — không có mốc nào để nhắc.');
return Command::SUCCESS;
}
// Chỉ quét hoá đơn nằm trong cửa sổ nhắc [mốc sớm nhất … mốc muộn nhất], để không
// kéo cả bảng và không đụng vào hoá đơn cũ đã qua mốc cuối (vd. dữ liệu import từ WHMCS).
$earliest = min($offsets);
$latest = InvoiceReminderService::reminderCutoff($offsets);
$invoices = Invoice::query()
->with('customer')
->whereNotNull('due_date')
->whereIn('status', InvoicePaymentService::PAYABLE_STATUSES)
->whereDate('due_date', '<=', now()->subDays($earliest)->toDateString())
->whereDate('due_date', '>=', now()->subDays($latest)->toDateString())
->get();
$this->info("invoices:send-reminders | Xét {$invoices->count()} hoá đơn.");
$sent = 0;
foreach ($invoices as $invoice) {
$dueStep = $reminders->dueStep($invoice);
if ($dueStep <= (int) $invoice->reminder_step) {
continue;
}
if ($dryRun) {
$this->line(sprintf(
' [dry-run] Hoá đơn #%s hạn %s — mốc %d/%d',
$invoice->invoice_number,
$invoice->due_date->format('Y-m-d'),
$dueStep,
count($offsets),
));
continue;
}
try {
if ($reminders->remind($invoice)) {
$sent++;
$this->line(" [sent] Hoá đơn #{$invoice->invoice_number} → {$invoice->customer?->email}");
}
} catch (\Throwable $e) {
Log::error('invoices:send-reminders error', [
'invoice_id' => $invoice->id,
'error' => $e->getMessage(),
]);
$this->error(" [error] Hoá đơn #{$invoice->invoice_number}: " . $e->getMessage());
}
}
$this->info("invoices:send-reminders | Đã gửi {$sent} email nhắc.");
return Command::SUCCESS;
}
}
+105 -8
View File
@@ -4,7 +4,10 @@ namespace App\Console\Commands;
use App\Enums\Invoice\InvoiceStatus;
use App\Helpers\OrderHelper;
use App\Jobs\VirtualMachine\Action\RemoveVirtualMachine;
use App\Jobs\VirtualMachine\Action\VMJobs;
use App\Models\OrderItem;
use App\Repositories\Api\Service\ServiceRepository;
use App\Models\Invoice;
use App\Models\InvoiceItem;
use App\Models\Service;
@@ -55,15 +58,17 @@ class ServiceExpiry extends Command
foreach ($expired as $service) {
try {
// Fix: diffInDays is always positive; guard with greaterThan to avoid edge case at exact boundary
// Hướng tính tường minh (mốc quá khứ → now), absolute: true để không phụ thuộc
// việc Carbon 2 trả trị tuyệt đối — Carbon 3 trả số âm, điều kiện >= 30 sẽ
// không bao giờ đúng và không dịch vụ nào bị terminate nữa, hoàn toàn im lặng.
$daysExpired = now()->greaterThan($service->expires_at)
? (int) now()->diffInDays($service->expires_at)
? (int) $service->expires_at->diffInDays(now(), absolute: true)
: 0;
// --- 30-day threshold: terminate (idempotent) ---
if ($daysExpired >= 30) {
if ($daysExpired >= 30 + (int) config('billing.terminate_grace_days', 0)) {
if ($dryRun) {
$this->line(" [dry-run:terminate] Service #{$service->id}");
$this->line(" [dry-run:terminate] Service #{$service->id}" . ($service->vm_id ? " + thu hồi VM #{$service->vm_id}" : ''));
continue;
}
@@ -71,6 +76,8 @@ class ServiceExpiry extends Command
$service->update(['status' => 'terminated']);
Log::info('Service terminated', ['service_id' => $service->id, 'days_overdue' => $daysExpired]);
$this->line(" [terminated] Service #{$service->id}");
$this->reclaimResources($service);
}
continue;
}
@@ -118,6 +125,75 @@ class ServiceExpiry extends Command
return Command::SUCCESS;
}
/**
* Giá gia hạn lấy theo BẢNG GIÁ HIỆN HÀNH tại thời điểm xuất hoá đơn, không đóng băng theo
* giá lúc mua — nếu không, tăng giá sẽ không bao giờ áp được cho khách cũ và khuyến mãi
* năm đầu bị gia hạn vĩnh viễn với giá khuyến mãi. Không tra được giá hiện hành (sản phẩm
* ngừng bán, chu kỳ bị tắt) thì lùi về giá cũ để vẫn xuất được hoá đơn.
*/
private function renewalPrice(OrderItem $orderItem): int
{
$legacyPrice = (int) $orderItem->price;
if (! config('billing.renewal_uses_current_price', true) || ! $orderItem->product_id || ! $orderItem->billing_cycle) {
return $legacyPrice;
}
try {
$current = app(ServiceRepository::class)->getPricing($orderItem->product_id, $orderItem->billing_cycle);
} catch (\Throwable $e) {
Log::warning('service:expiry | không tra được giá hiện hành, dùng giá cũ', [
'order_item_id' => $orderItem->id,
'error' => $e->getMessage(),
]);
return $legacyPrice;
}
if ($current === null || (int) $current <= 0) {
Log::info('service:expiry | không có giá hiện hành cho chu kỳ này, dùng giá cũ', [
'order_item_id' => $orderItem->id,
'billing_cycle' => $orderItem->billing_cycle,
]);
return $legacyPrice;
}
if ((int) $current !== $legacyPrice) {
Log::info('service:expiry | giá gia hạn đổi theo bảng giá hiện hành', [
'order_item_id' => $orderItem->id,
'old_price' => $legacyPrice,
'new_price' => (int) $current,
]);
}
return (int) $current;
}
/**
* Trả tài nguyên về hạ tầng khi dịch vụ đã chết hẳn. Trước đây terminate chỉ đổi status,
* VM vẫn nằm trên vCenter và hosting vẫn chiếm quota — công ty gánh chi phí cho dịch vụ
* không ai trả tiền.
*
* ponytail: hosting chỉ Suspend vì Http\Services\Hosting chưa có API xoá tài khoản —
* chỉ bọc delete khi nhà cung cấp mở endpoint, đừng đoán.
*/
private function reclaimResources(Service $service): void
{
if ($service->vm_id) {
dispatch(new RemoveVirtualMachine((int) $service->vm_id));
Log::info('Terminate: dispatched VM removal', ['service_id' => $service->id, 'vm_id' => $service->vm_id]);
$this->line(" [reclaim] VM #{$service->vm_id} đã xếp hàng xoá.");
return;
}
if ($service->orderItem?->product?->product_type === 'hosting') {
Log::warning('Terminate: hosting account cần thu hồi thủ công (chưa có API xoá)', [
'service_id' => $service->id,
'server_name' => $service->server_name,
]);
$this->line(" [reclaim] Hosting {$service->server_name}: cần thu hồi thủ công.");
}
}
private function createRenewalInvoiceIfMissing(Service $service, bool $dryRun = false): void
{
// Fresh DB query via invoice_id FK — does NOT rely on stale eager-loaded relation.
@@ -133,14 +209,35 @@ class ServiceExpiry extends Command
}
$orderItem = $service->orderItem;
if (!$orderItem || (int) $orderItem->price <= 0) {
Log::warning('service:expiry | Cannot create renewal invoice: no order item or zero price', ['service_id' => $service->id]);
if (!$orderItem) {
Log::warning('service:expiry | Cannot create renewal invoice: no order item', ['service_id' => $service->id]);
return;
}
$price = (int) $orderItem->price;
// Dịch vụ import từ WHMCS có services.invoice_id = NULL dù hoá đơn gia hạn của nó đã
// được đồng bộ sang — chỉ dựa vào invoice_id sẽ xuất trùng một hoá đơn nữa cho cùng kỳ.
// Soát thêm theo order: đơn đã có hoá đơn đang mở thì coi như đã xuất.
$openInvoiceOnOrder = Invoice::where('order_id', $orderItem->order_id)
->whereIn('status', [InvoiceStatus::UNPAID->value, InvoiceStatus::OVERDUE->value])
->first();
if ($openInvoiceOnOrder) {
// Trỏ service về hoá đơn đang mở để lần chạy sau đi vào nhánh nhanh ở trên.
if ((int) $service->invoice_id !== (int) $openInvoiceOnOrder->id) {
$service->update(['invoice_id' => $openInvoiceOnOrder->id]);
}
$this->line(" [skip] Service #{$service->id}: đơn #{$orderItem->order_id} đã có hoá đơn {$openInvoiceOnOrder->invoice_number} đang mở.");
return;
}
$price = $this->renewalPrice($orderItem);
$months = $orderItem->months ?? 1;
if ($price <= 0) {
Log::warning('service:expiry | Cannot create renewal invoice: zero price', ['service_id' => $service->id]);
return;
}
if ($dryRun) {
$this->line(" [dry-run:invoice] Would create renewal invoice for Service #{$service->id} on Order #{$orderItem->order_id}, amount " . number_format($price) . ' VND.');
return;
@@ -163,7 +260,7 @@ class ServiceExpiry extends Command
'status' => InvoiceStatus::UNPAID->value,
'due_date' => $service->expires_at && $service->expires_at->isFuture()
? $service->expires_at->toDateString()
: now()->addDays(7)->toDateString(),
: now()->addDays((int) config('billing.renewal_due_days', 7))->toDateString(),
'billing_period_start' => $periodStart->toDateString(),
'billing_period_end' => $periodEnd->toDateString(),
]);
+6
View File
@@ -55,6 +55,12 @@ class Kernel extends ConsoleKernel
->timezone("Asia/Ho_Chi_Minh")
->dailyAt("00:10")
->withoutOverlapping();
/* Nhắc thanh toán trước/đúng/sau hạn theo billing.reminder_offsets */
$schedule->command('invoices:send-reminders')
->timezone("Asia/Ho_Chi_Minh")
->dailyAt("08:00")
->withoutOverlapping();
}
/**