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 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', ]); } private function createServiceWithCycle(string $billingCycle, int $cycleMonths, 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' => $billingCycle, 'months' => $cycleMonths, '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_annual_service_for_exactly_one_cycle_charges_the_annual_price(): void { $service = $this->createServiceWithCycle('annually', 12, 1_800_000); ProductPricings::create([ 'product_id' => $this->product->id, 'billing_cycle' => 'annually', 'price' => 1_800_000, 'is_active' => true, ]); $response = $this->actingAsCustomer()->postJson( "/v1/client/dich-vu/{$service->id}/renew", ['months' => 12], ); $response->assertOk(); $response->assertJsonPath('data.total', 1_800_000); $this->assertDatabaseHas('invoices', [ 'id' => $response->json('data.invoice_id'), 'total' => 1_800_000, ]); } public function test_renew_annual_service_with_months_not_matching_the_cycle_is_rejected(): void { $service = $this->createServiceWithCycle('annually', 12, 1_800_000); ProductPricings::create([ 'product_id' => $this->product->id, 'billing_cycle' => 'annually', 'price' => 1_800_000, 'is_active' => true, ]); $response = $this->actingAsCustomer()->postJson( "/v1/client/dich-vu/{$service->id}/renew", ['months' => 1], ); $response->assertStatus(422); $this->assertSame(0, Invoice::where('customer_id', $this->customer->id)->count()); } public function test_renew_charges_the_recurring_addon_price_on_top_of_the_service_price(): void { $service = $this->createServiceWithOldPrice(90_000); $addon = Addon::create([ 'type' => $this->product->product_type, 'code' => 'ram', 'name' => 'RAM', 'price' => 50_000, 'min' => 1, 'max' => 64, 'step' => 1, 'status' => true, ]); $service->orderItem->update(['addons_meta' => [[ 'addon_id' => $addon->id, 'code' => 'ram', 'name' => 'RAM', 'qty' => 2, 'price' => 40_000, 'subtotal' => 80_000, ]]]); ServiceAddon::create([ 'service_id' => $service->id, 'addon_type' => 'ram', 'addon_value' => '6144', 'price' => 50_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(); $response->assertJsonPath('data.total', 200_000); $invoiceId = $response->json('data.invoice_id'); $this->assertDatabaseHas('invoice_items', [ 'invoice_id' => $invoiceId, 'type' => 'renewal', 'unit_price' => 100_000, ]); $this->assertDatabaseHas('invoice_items', [ 'invoice_id' => $invoiceId, 'type' => 'addon', 'quantity' => 2, 'total_price' => 100_000, ]); } 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()); } }