customer = Customer::factory()->create(); $this->product = Product::factory()->create([ 'product_type' => 'hosting', 'is_active' => true, ]); } private function actingAsCustomer(): static { return $this->actingAs($this->customer, 'client') ->withHeaders(['x-access-token' => $this->buildClientAccessToken()]); } /** Encrypts the shared secret the same way ClientAccessTokenCheck expects, for test requests. */ private function buildClientAccessToken(): string { $key = hex2bin((string) config('services.client_api.key')); $secret = (string) config('services.client_api.secret'); $iv = random_bytes(16); $ciphertext = openssl_encrypt($secret, 'aes-256-cbc', $key, OPENSSL_RAW_DATA, $iv); return base64_encode($iv . $ciphertext); } private function createServiceWithOldPrice(int $oldPrice): Service { $order = Order::create([ 'order_num' => 'ORD-' . uniqid(), 'customer_id' => $this->customer->id, 'sub_total' => $oldPrice, 'total' => $oldPrice, 'status' => OrderStatus::ACTIVE->value, ]); $orderItem = OrderItem::create([ 'order_id' => $order->id, 'product_id' => $this->product->id, 'billing_cycle' => 'monthly', 'months' => 1, 'quantity' => 1, 'price' => $oldPrice, ]); return Service::create([ 'customer_id' => $this->customer->id, 'product_id' => $this->product->id, 'order_item_id' => $orderItem->id, 'status' => 'active', 'server_name' => 'test-server', ]); } public function test_renew_uses_current_catalog_price_not_the_original_order_price(): void { $service = $this->createServiceWithOldPrice(90_000); // Giá sản phẩm hiện tại đã tăng lên 100.000 kể từ lúc khách đặt mua. ProductPricings::create([ 'product_id' => $this->product->id, 'billing_cycle' => 'monthly', 'price' => 100_000, 'is_active' => true, ]); $response = $this->actingAsCustomer()->postJson( "/v1/client/dich-vu/{$service->id}/renew", ['months' => 2], ); $response->assertOk(); $response->assertJsonPath('data.total', 200_000); $this->assertDatabaseHas('invoices', [ 'id' => $response->json('data.invoice_id'), 'total' => 200_000, ]); } public function test_renew_fails_when_product_pricing_is_no_longer_active(): void { $service = $this->createServiceWithOldPrice(90_000); ProductPricings::create([ 'product_id' => $this->product->id, 'billing_cycle' => 'monthly', 'price' => 100_000, 'is_active' => false, ]); $response = $this->actingAsCustomer()->postJson( "/v1/client/dich-vu/{$service->id}/renew", ['months' => 1], ); $response->assertStatus(400); $this->assertSame(0, Invoice::where('customer_id', $this->customer->id)->count()); } public function test_renew_reuses_original_order_instead_of_creating_a_new_one(): void { $service = $this->createServiceWithOldPrice(90_000); $originalOrderId = $service->orderItem->order_id; ProductPricings::create([ 'product_id' => $this->product->id, 'billing_cycle' => 'monthly', 'price' => 100_000, 'is_active' => true, ]); $response = $this->actingAsCustomer()->postJson( "/v1/client/dich-vu/{$service->id}/renew", ['months' => 1], ); $response->assertOk(); $response->assertJsonPath('data.order_id', $originalOrderId); // Order 1:N Invoice — no second Order row is ever created for a renewal. $this->assertSame(1, Order::where('customer_id', $this->customer->id)->count()); $this->assertDatabaseHas('invoices', [ 'id' => $response->json('data.invoice_id'), 'order_id' => $originalOrderId, 'invoice_type' => 2, ]); } public function test_renewal_invoice_has_a_billing_period_and_is_not_duplicated_for_the_same_period(): void { $service = $this->createServiceWithOldPrice(90_000); ProductPricings::create([ 'product_id' => $this->product->id, 'billing_cycle' => 'monthly', 'price' => 100_000, 'is_active' => true, ]); $response = $this->actingAsCustomer()->postJson( "/v1/client/dich-vu/{$service->id}/renew", ['months' => 1], ); $response->assertOk(); $invoiceId = $response->json('data.invoice_id'); $this->assertDatabaseHas('invoices', ['id' => $invoiceId]); $invoice = Invoice::find($invoiceId); $this->assertNotNull($invoice->billing_period_start); $this->assertNotNull($invoice->billing_period_end); // Calling renew again while the invoice is still open must return the SAME // invoice, not create a duplicate for the same billing period. $second = $this->actingAsCustomer()->postJson( "/v1/client/dich-vu/{$service->id}/renew", ['months' => 1], ); $second->assertOk(); $second->assertJsonPath('data.invoice_id', $invoiceId); $this->assertSame(1, Invoice::where('customer_id', $this->customer->id)->where('invoice_type', 2)->count()); } /** * Đúng flow đề bài mục 6: invoice gia hạn quá due_date -> OVERDUE, KHÔNG tạo invoice mới. * Verify khách bấm "Gia hạn" lại LÚC invoice đang OVERDUE (chưa qua billing cycle tiếp theo) * vẫn nhận về đúng invoice cũ, không bị tạo trùng. */ public function test_renew_while_existing_invoice_is_overdue_returns_the_same_invoice(): void { $service = $this->createServiceWithOldPrice(90_000); ProductPricings::create([ 'product_id' => $this->product->id, 'billing_cycle' => 'monthly', 'price' => 100_000, 'is_active' => true, ]); $first = $this->actingAsCustomer()->postJson( "/v1/client/dich-vu/{$service->id}/renew", ['months' => 1], ); $first->assertOk(); $invoiceId = $first->json('data.invoice_id'); // Invoice quá due_date -> cron invoices:check-overdue chuyển sang OVERDUE (flow thật). Invoice::whereKey($invoiceId)->update(['due_date' => now()->subDay()->toDateString()]); $this->artisan('invoices:check-overdue')->assertExitCode(0); $this->assertSame(InvoiceStatus::OVERDUE->value, (int) Invoice::find($invoiceId)->status); // Khách bấm "Gia hạn" lại trong khi invoice đang OVERDUE — không được tạo invoice #2. $second = $this->actingAsCustomer()->postJson( "/v1/client/dich-vu/{$service->id}/renew", ['months' => 1], ); $second->assertOk(); $second->assertJsonPath('data.invoice_id', $invoiceId); $this->assertSame(1, Invoice::where('customer_id', $this->customer->id)->where('invoice_type', 2)->count()); } }