update
This commit is contained in:
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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(),
|
||||
]);
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
Reference in New Issue
Block a user