create(['total' => $balance]); $product = Product::factory()->create(['product_type' => 'hosting', 'is_active' => true]); $order = Order::create([ 'order_num' => 'ORD-' . uniqid(), 'customer_id' => $customer->id, 'sub_total' => 90_000, 'total' => 90_000, 'status' => 1, ]); $item = OrderItem::create([ 'order_id' => $order->id, 'product_id' => $product->id, 'billing_cycle' => 'monthly', 'months' => 1, 'quantity' => 1, 'price' => 90_000, ]); return Service::create($attributes + [ 'customer_id' => $customer->id, 'product_id' => $product->id, 'order_item_id' => $item->id, 'status' => 'active', 'server_name' => 'srv', 'expires_at' => now()->addDays(3), ]); } private function vmFor(Service $service, ?int $dueDate): ModuleVmware { $vm = ModuleVmware::create([ 'name' => 'vm-' . uniqid(), 'customer_id' => $service->customer_id, 'next_due_date' => $dueDate, ]); $service->update(['vm_id' => $vm->id]); return $vm; } public function test_changing_service_expiry_moves_vmware_due_date(): void { $service = $this->service(); $vm = $this->vmFor($service, 1); $newExpiry = now()->addMonths(2)->startOfSecond(); $service->update(['expires_at' => $newExpiry]); $this->assertSame($newExpiry->timestamp, (int) $vm->fresh()->next_due_date); } public function test_reconcile_is_idempotent_and_dry_run_writes_nothing(): void { $service = $this->service(); $vm = $this->vmFor($service, null); ModuleVmware::whereKey($vm->id)->update(['next_due_date' => 1]); $this->artisan('service:reconcile-expiry', ['--dry-run' => true])->assertExitCode(0); $this->assertSame(1, (int) $vm->fresh()->next_due_date); $this->artisan('service:reconcile-expiry')->assertExitCode(0); $this->assertSame($service->fresh()->expires_at->timestamp, (int) $vm->fresh()->next_due_date); $before = $vm->fresh()->updated_at; $this->artisan('service:reconcile-expiry')->expectsOutputToContain('lệch module_vmware: 0')->assertExitCode(0); $this->assertEquals($before, $vm->fresh()->updated_at); } public function test_reconcile_backfills_service_expiry_from_legacy_vm(): void { $service = $this->service(['expires_at' => null]); $due = now()->addDays(20)->startOfSecond(); $this->vmFor($service, $due->timestamp); $this->artisan('service:reconcile-expiry')->assertExitCode(0); $this->assertSame($due->timestamp, $service->fresh()->expires_at->timestamp); } public function test_auto_renew_without_balance_notifies_once_per_day(): void { Mail::fake(); $this->seed(TemplateSeeder::class); $service = $this->service(['auto_renew' => true], balance: 1_000); $this->artisan('service:expiry')->assertExitCode(0); $this->artisan('service:expiry')->assertExitCode(0); Mail::assertQueued(AutoRenewFailedMail::class, 1); $this->assertSame(1, Notification::where('type', 'auto_renew_failed')->where('recipient_id', $service->customer_id)->count()); $this->assertNotNull($service->fresh()->invoice_id); $this->assertSame(1_000, (int) $service->customer->fresh()->total); } public function test_auto_renew_failure_mail_respects_auto_email_gate(): void { Mail::fake(); $this->seed(TemplateSeeder::class); Setting::updateOrCreate(['key' => 'email_settings'], ['value' => json_encode(['auto_disabled' => ['auto_renew_failed']])]); $service = $this->service(['auto_renew' => true], balance: 0); $this->artisan('service:expiry')->assertExitCode(0); Mail::assertNotQueued(AutoRenewFailedMail::class); $this->assertSame(1, Notification::where('type', 'auto_renew_failed')->where('recipient_id', $service->customer_id)->count()); } }