259 lines
11 KiB
PHP
259 lines
11 KiB
PHP
<?php
|
|
|
|
declare(strict_types=1);
|
|
|
|
namespace Tests\Feature\Console;
|
|
|
|
use App\Enums\Invoice\InvoiceStatus;
|
|
use App\Enums\Order\OrderStatus;
|
|
use App\Models\Customer;
|
|
use App\Models\Invoice;
|
|
use App\Models\Order;
|
|
use App\Models\OrderItem;
|
|
use App\Models\Product;
|
|
use App\Models\Service;
|
|
use Illuminate\Foundation\Testing\RefreshDatabase;
|
|
use Tests\TestCase;
|
|
|
|
class ServiceExpiryTest extends TestCase
|
|
{
|
|
use RefreshDatabase;
|
|
|
|
private function createExpiringService(int $price): Service
|
|
{
|
|
$customer = Customer::factory()->create();
|
|
$product = Product::factory()->create(['product_type' => 'hosting', 'is_active' => true]);
|
|
|
|
$order = Order::create([
|
|
'order_num' => 'ORD-' . uniqid(),
|
|
'customer_id' => $customer->id,
|
|
'sub_total' => $price,
|
|
'total' => $price,
|
|
'status' => OrderStatus::ACTIVE->value,
|
|
]);
|
|
|
|
$orderItem = OrderItem::create([
|
|
'order_id' => $order->id,
|
|
'product_id' => $product->id,
|
|
'billing_cycle' => 'monthly',
|
|
'months' => 1,
|
|
'quantity' => 1,
|
|
'price' => $price,
|
|
]);
|
|
|
|
return Service::create([
|
|
'customer_id' => $customer->id,
|
|
'product_id' => $product->id,
|
|
'order_item_id' => $orderItem->id,
|
|
'status' => 'active',
|
|
'server_name' => 'test-server',
|
|
'expires_at' => now()->addDays(3),
|
|
]);
|
|
}
|
|
|
|
public function test_expiry_command_creates_renewal_invoice_on_the_original_order(): void
|
|
{
|
|
$service = $this->createExpiringService(90_000);
|
|
$originalOrderId = $service->orderItem->order_id;
|
|
|
|
$this->artisan('service:expiry')->assertExitCode(0);
|
|
|
|
$service->refresh();
|
|
$this->assertNotNull($service->invoice_id);
|
|
|
|
$invoice = Invoice::find($service->invoice_id);
|
|
$this->assertSame($originalOrderId, $invoice->order_id);
|
|
$this->assertSame(2, $invoice->invoice_type);
|
|
$this->assertSame(InvoiceStatus::UNPAID->value, $invoice->status);
|
|
$this->assertNotNull($invoice->billing_period_start);
|
|
$this->assertNotNull($invoice->billing_period_end);
|
|
|
|
// Order 1:N Invoice — no second Order row created for the renewal.
|
|
$this->assertSame(1, Order::query()->count());
|
|
}
|
|
|
|
public function test_running_expiry_command_twice_does_not_duplicate_the_renewal_invoice(): void
|
|
{
|
|
$service = $this->createExpiringService(90_000);
|
|
|
|
$this->artisan('service:expiry')->assertExitCode(0);
|
|
$firstInvoiceId = $service->refresh()->invoice_id;
|
|
|
|
$this->artisan('service:expiry')->assertExitCode(0);
|
|
$secondInvoiceId = $service->refresh()->invoice_id;
|
|
|
|
$this->assertSame($firstInvoiceId, $secondInvoiceId);
|
|
$this->assertSame(1, Invoice::where('invoice_type', 2)->count());
|
|
}
|
|
|
|
public function test_cancelled_service_does_not_get_a_renewal_invoice(): void
|
|
{
|
|
$service = $this->createExpiringService(90_000);
|
|
$service->update(['status' => 'cancelled', 'expires_at' => now()->subDay()]);
|
|
|
|
$this->artisan('service:expiry')->assertExitCode(0);
|
|
|
|
$this->assertNull($service->refresh()->invoice_id);
|
|
$this->assertSame(0, Invoice::where('invoice_type', 2)->count());
|
|
}
|
|
|
|
/**
|
|
* Đúng flow đề bài mục 6: Invoice gia hạn quá due_date -> chuyển OVERDUE (không tạo Invoice mới).
|
|
* Test này verify bước tiếp theo: cron service:expiry chạy lại SAU KHI invoice đã OVERDUE
|
|
* (không còn UNPAID nữa) vẫn phải nhận ra invoice đang mở và KHÔNG tạo invoice #2 trùng kỳ.
|
|
*/
|
|
public function test_expiry_command_does_not_duplicate_invoice_after_it_became_overdue(): void
|
|
{
|
|
$service = $this->createExpiringService(90_000);
|
|
|
|
// Lần chạy 1: sinh invoice gia hạn (UNPAID).
|
|
$this->artisan('service:expiry')->assertExitCode(0);
|
|
$invoiceId = $service->refresh()->invoice_id;
|
|
$this->assertNotNull($invoiceId);
|
|
|
|
// Giả lập thời gian trôi qua khỏi due_date rồi cron invoices:check-overdue chạy thật,
|
|
// chuyển invoice sang OVERDUE — đúng như flow thật, không set status tay.
|
|
Invoice::whereKey($invoiceId)->update(['due_date' => now()->subDay()->toDateString()]);
|
|
$this->artisan('invoices:check-overdue')->assertExitCode(0);
|
|
|
|
$invoice = Invoice::find($invoiceId);
|
|
$this->assertSame(InvoiceStatus::OVERDUE->value, (int) $invoice->status);
|
|
|
|
// Lần chạy 2: service:expiry chạy lại (vd. mỗi 5 phút) trong khi invoice đang OVERDUE.
|
|
$this->artisan('service:expiry')->assertExitCode(0);
|
|
|
|
$this->assertSame($invoiceId, $service->refresh()->invoice_id);
|
|
$this->assertSame(1, Invoice::where('invoice_type', 2)->count());
|
|
// Vẫn đúng là OVERDUE — service:expiry không được đụng vào status của invoice.
|
|
$this->assertSame(InvoiceStatus::OVERDUE->value, (int) $invoice->fresh()->status);
|
|
}
|
|
|
|
/**
|
|
* Hoá đơn gia hạn dùng lại order_id gốc. Khi nó quá hạn, cron KHÔNG được kéo đơn đang chạy
|
|
* về PENDING — vừa hiển thị sai, vừa làm hasPendingOrder() khoá khách đặt đơn mới.
|
|
*/
|
|
public function test_overdue_renewal_invoice_does_not_reset_an_active_order(): void
|
|
{
|
|
$service = $this->createExpiringService(90_000);
|
|
$order = $service->orderItem->order;
|
|
$this->assertSame(OrderStatus::ACTIVE->value, (int) $order->status);
|
|
|
|
$this->artisan('service:expiry')->assertExitCode(0);
|
|
Invoice::whereKey($service->refresh()->invoice_id)->update(['due_date' => now()->subDay()->toDateString()]);
|
|
|
|
$this->artisan('invoices:check-overdue')->assertExitCode(0);
|
|
|
|
$this->assertSame(OrderStatus::ACTIVE->value, (int) $order->fresh()->status);
|
|
}
|
|
|
|
/** Giá gia hạn phải theo bảng giá hiện hành, không đóng băng theo giá lúc mua. */
|
|
public function test_renewal_invoice_uses_the_current_price_list(): void
|
|
{
|
|
$service = $this->createExpiringService(90_000);
|
|
|
|
$service->orderItem->product->pricings()->create([
|
|
'billing_cycle' => 'monthly',
|
|
'price' => 120_000,
|
|
'is_active' => 1,
|
|
]);
|
|
|
|
$this->artisan('service:expiry')->assertExitCode(0);
|
|
|
|
$invoice = Invoice::find($service->refresh()->invoice_id);
|
|
$this->assertSame(120_000, (int) $invoice->total);
|
|
}
|
|
|
|
/** Không có giá hiện hành (sản phẩm ngừng bán) thì lùi về giá cũ, vẫn xuất được hoá đơn. */
|
|
public function test_renewal_falls_back_to_the_original_price_when_no_current_price_exists(): void
|
|
{
|
|
$service = $this->createExpiringService(90_000);
|
|
|
|
$this->artisan('service:expiry')->assertExitCode(0);
|
|
|
|
$invoice = Invoice::find($service->refresh()->invoice_id);
|
|
$this->assertSame(90_000, (int) $invoice->total);
|
|
}
|
|
|
|
public function test_auto_renew_service_is_paid_from_balance_and_extended(): void
|
|
{
|
|
$service = $this->createExpiringService(90_000);
|
|
$service->update(['auto_renew' => true]);
|
|
$service->customer->update(['total' => 200_000]);
|
|
$oldExpiry = $service->expires_at->copy();
|
|
|
|
$this->artisan('service:expiry')->assertExitCode(0);
|
|
|
|
$service->refresh();
|
|
$this->assertSame(InvoiceStatus::PAID->value, Invoice::find($service->invoice_id)->status);
|
|
$this->assertTrue($service->expires_at->greaterThan($oldExpiry));
|
|
$this->assertSame(110_000, (int) $service->customer->fresh()->total);
|
|
|
|
$log = \App\Models\ActivityLog::where('subject_id', $service->id)->where('event', 'renewed')->first();
|
|
$this->assertSame('system', $log->causer_type);
|
|
$this->assertSame('balance', $log->new_values['payment_method']);
|
|
}
|
|
|
|
public function test_auto_renew_with_insufficient_balance_keeps_invoice_unpaid_and_retries_after_topup(): void
|
|
{
|
|
$service = $this->createExpiringService(90_000);
|
|
$service->update(['auto_renew' => true]);
|
|
$service->customer->update(['total' => 10_000]);
|
|
|
|
$this->artisan('service:expiry')->assertExitCode(0);
|
|
$this->assertSame(InvoiceStatus::UNPAID->value, Invoice::find($service->refresh()->invoice_id)->status);
|
|
$this->assertSame(10_000, (int) $service->customer->fresh()->total);
|
|
|
|
$service->customer->update(['total' => 100_000]);
|
|
$this->artisan('service:expiry')->assertExitCode(0);
|
|
$this->assertSame(InvoiceStatus::PAID->value, Invoice::find($service->invoice_id)->status);
|
|
}
|
|
|
|
public function test_service_without_auto_renew_is_not_charged(): void
|
|
{
|
|
$service = $this->createExpiringService(90_000);
|
|
$service->customer->update(['total' => 200_000]);
|
|
|
|
$this->artisan('service:expiry')->assertExitCode(0);
|
|
|
|
$this->assertSame(InvoiceStatus::UNPAID->value, Invoice::find($service->refresh()->invoice_id)->status);
|
|
$this->assertSame(200_000, (int) $service->customer->fresh()->total);
|
|
}
|
|
|
|
public function test_renewal_invoice_creates_customer_notification_and_queues_email(): void
|
|
{
|
|
\Illuminate\Support\Facades\Queue::fake();
|
|
\Illuminate\Support\Facades\DB::table('setting')->updateOrInsert(['key' => 'email_settings'], ['value' => json_encode(['auto_disabled' => []])]);
|
|
$service = $this->createExpiringService(90_000);
|
|
|
|
$this->artisan('service:expiry')->assertExitCode(0);
|
|
|
|
$invoice = Invoice::find($service->refresh()->invoice_id);
|
|
$notification = \App\Models\Notification::where('recipient_type', 'client')
|
|
->where('recipient_id', $service->customer_id)->first();
|
|
$this->assertNotNull($notification);
|
|
$this->assertSame('renewal_invoice', $notification->type);
|
|
$this->assertStringContainsString($invoice->invoice_number, $notification->body);
|
|
$this->assertStringContainsString('?invoice=' . $invoice->invoice_number, $notification->link);
|
|
|
|
\Illuminate\Support\Facades\Queue::assertPushed(
|
|
\App\Jobs\Mail\SendInvoiceMailJob::class,
|
|
fn ($job) => $job->invoiceId === $invoice->id && $job->type === 'renewal',
|
|
);
|
|
|
|
$this->artisan('service:expiry')->assertExitCode(0);
|
|
$this->assertSame(1, \App\Models\Notification::where('recipient_id', $service->customer_id)->count());
|
|
}
|
|
|
|
public function test_email_is_skipped_when_invoice_notification_is_auto_disabled(): void
|
|
{
|
|
\Illuminate\Support\Facades\Queue::fake();
|
|
\Illuminate\Support\Facades\DB::table('setting')->updateOrInsert(['key' => 'email_settings'], ['value' => json_encode(['auto_disabled' => ['invoice_notification']])]);
|
|
$service = $this->createExpiringService(90_000);
|
|
|
|
$this->artisan('service:expiry')->assertExitCode(0);
|
|
|
|
$this->assertSame(1, \App\Models\Notification::where('recipient_id', $service->customer_id)->count());
|
|
\Illuminate\Support\Facades\Queue::assertNotPushed(\App\Jobs\Mail\SendInvoiceMailJob::class);
|
|
}
|
|
}
|