feat: nạp tiền thủ công mã 30 phút, gia hạn trừ số dư, đồng bộ domain từ Plesk

- Nạp tiền: mã thanh toán sống 30 phút (ManualDepositDraft), khôi phục/huỷ mã, lệnh dọn mã hết hạn; total_pending chỉ tính giao dịch đã nộp chứng từ, admin duyệt/từ chối/xoá giao dịch
- Gia hạn dịch vụ: phát hành invoice gia hạn trên order gốc và trừ số dư ngay
- Đồng bộ domain từ Plesk theo server, ServiceOrderRecorder tạo order/invoice cho dịch vụ admin dựng
- Danh sách dịch vụ trả field theo loại (service_plan, dns_records_count), đếm dịch vụ theo loại

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
baodev0
2026-10-11 23:07:51 +07:00
co-authored by Claude Opus 5.5
parent 10af41c5cf
commit c5115b52dd
53 changed files with 1887 additions and 613 deletions
@@ -0,0 +1,105 @@
<?php
declare(strict_types=1);
namespace Tests\Feature\Api\Admin\Customer;
use App\Models\Customer;
use App\Models\DomainPricing;
use App\Models\Product;
use App\Models\Service;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Spatie\Permission\Models\Role;
use Tests\TestCase;
class CreateDomainServicePricingTest extends TestCase
{
use RefreshDatabase;
private User $admin;
private Customer $customer;
private Product $product;
protected function setUp(): void
{
parent::setUp();
Role::firstOrCreate(['name' => 'Full Administrator', 'guard_name' => 'admin']);
$this->admin = User::factory()->create();
$this->admin->assignRole('Full Administrator');
$this->customer = Customer::factory()->create();
$this->product = Product::factory()->create(['product_type' => 'domain']);
DomainPricing::query()->delete();
DomainPricing::create(['type' => 'domain', 'tld' => '.vn', 'setup_fee' => '100000', 'first_year' => 400_000, 'recurring_fee' => '350000', 'is_active' => true]);
DomainPricing::create(['type' => 'domain', 'tld' => '.com.vn', 'setup_fee' => 'Miễn phí', 'first_year' => 300_000, 'recurring_fee' => '300000', 'is_active' => true]);
}
private function createService(array $data)
{
return $this->actingAs($this->admin, 'admin')->postJson(
"/api/v1/administrator/customers/{$this->customer->uuid}/services",
['product_id' => $this->product->id, 'payment_method' => 'transfer', 'billing_cycle' => 'biennially', ...$data],
);
}
public function test_domain_service_uses_domain_pricing_for_matched_tld(): void
{
$this->createService(['server_name' => 'Example.COM.vn'])->assertCreated();
$this->createService(['server_name' => 'example.vn'])->assertCreated();
$this->assertDatabaseHas('order_items', ['domain_name' => 'Example.COM.vn', 'price' => 600_000]);
$this->assertDatabaseHas('order_items', ['domain_name' => 'example.vn', 'price' => 850_000]);
}
public function test_domain_without_pricing_requires_amount(): void
{
$this->createService(['server_name' => 'example.xyz'])->assertStatus(422);
$this->assertSame(0, Service::count());
$this->createService(['server_name' => 'example.xyz', 'amount_paid' => 250_000])->assertCreated();
$this->assertDatabaseHas('order_items', ['domain_name' => 'example.xyz', 'price' => 250_000]);
}
public function test_saving_price_on_service_without_order_creates_unpaid_order_and_invoice(): void
{
$service = Service::create([
'customer_id' => $this->customer->id,
'product_id' => $this->product->id,
'server_name' => 'example.vn',
'status' => 'active',
]);
$url = "/api/v1/administrator/customers/{$this->customer->uuid}/services/{$service->id}";
$this->actingAs($this->admin, 'admin')->putJson($url, ['price' => 500_000, 'billing_cycle' => 'annually'])->assertOk();
$service->refresh();
$this->assertSame(500_000, (int) $service->orderItem->price);
$this->assertSame(1, (int) $service->invoice->status);
$this->assertSame(500_000, (int) $service->invoice->total);
$this->actingAs($this->admin, 'admin')->putJson($url, ['price' => 450_000])->assertOk();
$this->assertSame(450_000, (int) $service->invoice->fresh()->total);
$this->assertSame(450_000, (int) $service->invoice->invoiceItems()->value('total_price'));
$this->assertDatabaseCount('invoices', 1);
}
public function test_domain_quote_endpoint(): void
{
$url = '/api/v1/administrator/services/domain-quote';
$this->actingAs($this->admin, 'admin')->getJson("{$url}?domain=abc.vn&billing_cycle=triennially")
->assertOk()
->assertJsonPath('data.tld', '.vn')
->assertJsonPath('data.years', 3)
->assertJsonPath('data.price', 1_200_000);
$this->actingAs($this->admin, 'admin')->getJson("{$url}?domain=abc.xyz")
->assertOk()
->assertJsonPath('data', null);
}
}
@@ -0,0 +1,72 @@
<?php
declare(strict_types=1);
namespace Tests\Feature\Api\Admin;
use App\Models\Customer;
use App\Models\DnsDomain;
use App\Models\DomainPricing;
use App\Models\Product;
use App\Models\Service;
use App\Services\Domain\CustomerDomainServiceSync;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\DB;
use Tests\TestCase;
class CustomerDomainServiceSyncTest extends TestCase
{
use RefreshDatabase;
public function test_assign_update_and_detach_keep_domain_service_in_sync(): void
{
$customer = Customer::factory()->create();
$serverId = DB::table('dns_manager_servers')->insertGetId([
'ip_address' => '10.0.0.1',
'password' => 'x',
]);
$domain = DnsDomain::create([
'domain_name' => 'devro-tech.com',
'dns_manager_server_id' => $serverId,
'is_created' => DnsDomain::STATUS_CREATED,
]);
DomainPricing::query()->delete();
DomainPricing::create(['type' => 'domain', 'tld' => '.com', 'setup_fee' => '0', 'first_year' => 320_000, 'recurring_fee' => '340000', 'is_active' => true]);
$product = Product::factory()->create(['product_type' => 'domain', 'is_active' => true]);
$sync = app(CustomerDomainServiceSync::class);
$sync->syncCustomers($domain, [$customer->id]);
$service = Service::query()
->where('customer_id', $customer->id)
->where('type', 'domain')
->where('server_name', 'devro-tech.com')
->firstOrFail();
$this->assertSame('active', $service->status);
$this->assertSame(CustomerDomainServiceSync::AUTO_CREATED_NOTE, $service->note);
$this->assertSame($product->id, $service->product_id);
$this->assertSame('devro-tech.com', $service->orderItem->domain_name);
$this->assertSame(320_000, (int) $service->orderItem->price);
$this->assertSame('annually', $service->orderItem->billing_cycle);
$this->assertSame(1, (int) $service->invoice->status);
$this->assertSame(320_000, (int) $service->invoice->total);
$sync->syncCustomers($domain, [$customer->id]);
$this->assertSame(1, Service::query()->where('customer_id', $customer->id)->count());
$this->assertSame(1, $service->orderItem->order()->count());
$this->assertDatabaseCount('invoices', 1);
$sync->updateCustomerDomain($domain, $customer->id, '2027-07-21');
$this->assertSame('2027-07-21', $service->fresh()->expires_at?->toDateString());
$sync->syncCustomers($domain, []);
$this->assertSoftDeleted('services', ['id' => $service->id]);
$this->assertSame(3, (int) $service->invoice->fresh()->status);
$this->assertDatabaseMissing('dns_domain_user', [
'domain_id' => $domain->id,
'user_id' => $customer->id,
]);
}
}
@@ -0,0 +1,103 @@
<?php
declare(strict_types=1);
namespace Tests\Feature\Api\Admin\DnsManager;
use App\Events\DnsSyncCompleted;
use App\Jobs\DnsManager\SyncDnsFromPlesk;
use App\Models\DnsDomain;
use App\Models\User;
use App\Services\Plesk\PleskDomainService;
use App\Services\ServerService;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Event;
use Illuminate\Support\Facades\Queue;
use Mockery;
use Spatie\Permission\Models\Role;
use Tests\TestCase;
class SyncDomainsFromPleskTest extends TestCase
{
use RefreshDatabase;
private int $serverId;
private int $otherServerId;
protected function setUp(): void
{
parent::setUp();
$this->serverId = DB::table('dns_manager_servers')->insertGetId(['ip_address' => '10.0.0.1', 'password' => 'x']);
$this->otherServerId = DB::table('dns_manager_servers')->insertGetId(['ip_address' => '10.0.0.2', 'password' => 'x']);
}
private function runJob(array|false $pleskDomains): void
{
$plesk = Mockery::mock(PleskDomainService::class);
$plesk->shouldReceive('listDomains')->once()->andReturn($pleskDomains);
$plesk->shouldReceive('getLastError')->andReturn('boom');
$server = Mockery::mock(ServerService::class);
$server->shouldReceive('addServerById')->andReturn(true);
$server->shouldReceive('listRecords')->andReturn(['error' => false, 'data' => []]);
$this->app->instance(PleskDomainService::class, $plesk);
$this->app->instance(ServerService::class, $server);
$this->app->call([new SyncDnsFromPlesk($this->serverId), 'handle']);
}
public function test_imports_new_domains_and_links_pending_ones_on_selected_server(): void
{
Event::fake([DnsSyncCompleted::class]);
$pending = DnsDomain::create(['domain_name' => 'Pending.vn', 'dns_manager_server_id' => $this->serverId, 'is_created' => 0]);
$otherServer = DnsDomain::create(['domain_name' => 'other.vn', 'dns_manager_server_id' => $this->otherServerId, 'is_created' => 0]);
$cancelled = DnsDomain::create(['domain_name' => 'cancelled.vn', 'dns_manager_server_id' => $this->serverId, 'is_created' => DnsDomain::STATUS_CANCELLED]);
$this->runJob([
['id' => 1, 'name' => 'new.vn', 'base_domain_id' => 0],
['id' => 2, 'name' => 'pending.vn', 'base_domain_id' => 0],
['id' => 3, 'name' => 'other.vn', 'base_domain_id' => 0],
['id' => 4, 'name' => 'cancelled.vn', 'base_domain_id' => 0],
['id' => 5, 'name' => 'sub.new.vn', 'base_domain_id' => 1],
]);
$this->assertDatabaseHas('dns_manager_domain', ['domain_name' => 'new.vn', 'plesk_id' => 1, 'dns_manager_server_id' => $this->serverId, 'is_created' => 1]);
$this->assertDatabaseHas('dns_manager_domain', ['id' => $pending->id, 'plesk_id' => 2, 'is_created' => 1]);
$this->assertDatabaseHas('dns_manager_domain', ['id' => $otherServer->id, 'plesk_id' => null]);
$this->assertDatabaseHas('dns_manager_domain', ['id' => $cancelled->id, 'plesk_id' => null, 'is_created' => DnsDomain::STATUS_CANCELLED]);
$this->assertDatabaseMissing('dns_manager_domain', ['domain_name' => 'sub.new.vn']);
Event::assertDispatched(DnsSyncCompleted::class, fn ($e) => $e->imported === 2 && $e->failedDomains === []);
}
public function test_reports_failure_when_plesk_domain_list_unavailable(): void
{
Event::fake([DnsSyncCompleted::class]);
$this->runJob(false);
$this->assertDatabaseCount('dns_manager_domain', 0);
Event::assertDispatched(DnsSyncCompleted::class, fn ($e) => $e->failedDomains !== []);
}
public function test_endpoint_queues_sync_for_selected_server(): void
{
Queue::fake();
Role::firstOrCreate(['name' => 'Full Administrator', 'guard_name' => 'admin']);
$admin = User::factory()->create();
$admin->assignRole('Full Administrator');
$this->actingAs($admin, 'admin')
->postJson('/api/v1/administrator/domains/sync-dns', ['dns_manager_server_id' => 999999])
->assertStatus(422);
$this->actingAs($admin, 'admin')
->postJson('/api/v1/administrator/domains/sync-dns', ['dns_manager_server_id' => $this->serverId])
->assertOk();
Queue::assertPushed(SyncDnsFromPlesk::class, 1);
}
}
@@ -0,0 +1,32 @@
<?php
declare(strict_types=1);
namespace Tests\Feature\Api\Admin\Product;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Spatie\Permission\Models\Role;
use Tests\TestCase;
class ProductCategorySortOrderTest extends TestCase
{
use RefreshDatabase;
public function test_store_requires_sort_order(): void
{
Role::firstOrCreate(['name' => 'Full Administrator', 'guard_name' => 'admin']);
$admin = User::factory()->create();
$admin->assignRole('Full Administrator');
$url = '/api/v1/administrator/product-categories';
$this->actingAs($admin, 'admin')
->postJson($url, ['name' => 'Wordpress Hosting', 'sort_order' => null])
->assertStatus(422)
->assertJsonPath('errors.sort_order.0', 'Vui lòng nhập thứ tự hiển thị (sort_order).');
$this->actingAs($admin, 'admin')
->postJson($url, ['name' => 'Wordpress Hosting', 'sort_order' => 3])
->assertCreated();
}
}
@@ -0,0 +1,94 @@
<?php
declare(strict_types=1);
namespace Tests\Feature\Api\Client;
use App\Models\Customer;
use App\Models\DnsDomain;
use App\Models\DnsManagerNS;
use App\Models\Service;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\DB;
use Tests\TestCase;
class ClientServiceListTest extends TestCase
{
use RefreshDatabase;
public function test_customer_can_only_list_their_own_services_across_all_types(): void
{
$customer = Customer::factory()->create();
$otherCustomer = Customer::factory()->create();
$vps = Service::create([
'customer_id' => $customer->id,
'type' => 'vps',
'server_name' => 'vps.example.vn',
'status' => 'active',
]);
$hosting = Service::create([
'customer_id' => $customer->id,
'type' => 'hosting',
'server_name' => 'hosting.example.vn',
'status' => 'cancelled',
]);
$domain = Service::create([
'customer_id' => $customer->id,
'type' => 'domain',
'server_name' => 'example.vn',
'status' => 'active',
]);
$dnsServerId = DB::table('dns_manager_servers')->insertGetId([
'ip_address' => '10.0.0.1',
'password' => 'x',
]);
$dnsDomain = DnsDomain::create([
'domain_name' => 'example.vn',
'dns_manager_server_id' => $dnsServerId,
'is_created' => DnsDomain::STATUS_CREATED,
]);
$dnsDomain->customer()->attach($customer->id);
DnsManagerNS::create([
'dns_manager_domain_id' => $dnsDomain->id,
'type' => 'A',
'type_valid' => 'A',
'name' => 'example.vn',
'value' => '10.0.0.2',
'ttl' => 300,
]);
Service::create([
'customer_id' => $otherCustomer->id,
'type' => 'vps',
'server_name' => 'private.example.vn',
'status' => 'active',
]);
$response = $this->actingAs($customer, 'client')
->withHeaders(['x-access-token' => $this->buildClientAccessToken()])
->getJson('/v1/client/client-service')
->assertOk()
->assertJsonPath('error', false)
->assertJsonCount(3, 'data.items');
$items = collect($response->json('data.items'))->keyBy('uuid');
$this->assertSame('vps', $items[$vps->uuid]['type']);
$this->assertSame('hosting', $items[$hosting->uuid]['type']);
$this->assertSame(1, $items[$domain->uuid]['dns_records_count']);
$this->assertFalse($items->contains('server_name', 'private.example.vn'));
$this->assertSame(['os', 'specs'], array_values(array_intersect(
array_keys($items[$vps->uuid]),
['os', 'specs', 'service_plan', 'dns_records_count'],
)));
$this->assertSame(['service_plan'], array_values(array_intersect(
array_keys($items[$hosting->uuid]),
['os', 'specs', 'service_plan', 'dns_records_count'],
)));
$this->assertSame(['dns_records_count'], array_values(array_intersect(
array_keys($items[$domain->uuid]),
['os', 'specs', 'service_plan', 'dns_records_count'],
)));
}
}
+46 -294
View File
@@ -5,8 +5,6 @@ declare(strict_types=1);
namespace Tests\Feature\Api\Service;
use App\Enums\Invoice\InvoiceStatus;
use App\Models\Addon;
use App\Enums\Order\OrderStatus;
use App\Models\Customer;
use App\Models\Invoice;
use App\Models\Order;
@@ -14,7 +12,6 @@ use App\Models\OrderItem;
use App\Models\Product;
use App\Models\ProductPricings;
use App\Models\Service;
use App\Models\ServiceAddon;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Tests\TestCase;
@@ -23,330 +20,85 @@ class RenewServiceTest extends TestCase
use RefreshDatabase;
private Customer $customer;
private Product $product;
private Service $service;
private Order $order;
protected function setUp(): void
{
parent::setUp();
$this->customer = Customer::factory()->create();
$this->product = Product::factory()->create([
'product_type' => 'hosting',
'is_active' => true,
]);
}
$this->customer = Customer::factory()->create(['total' => 100_000]);
$product = Product::factory()->create(['product_type' => 'hosting', 'is_active' => true]);
ProductPricings::create(['product_id' => $product->id, 'billing_cycle' => 'monthly', 'price' => 15_000, '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(),
$this->order = Order::create([
'order_num' => 'ORD-RENEW-TEST',
'customer_id' => $this->customer->id,
'sub_total' => $oldPrice,
'total' => $oldPrice,
'status' => OrderStatus::ACTIVE->value,
'total' => 15_000,
'status' => 1,
]);
$orderItem = OrderItem::create([
'order_id' => $order->id,
'product_id' => $this->product->id,
'order_id' => $this->order->id,
'product_id' => $product->id,
'billing_cycle' => 'monthly',
'months' => 1,
'quantity' => 1,
'price' => $oldPrice,
'price' => 15_000,
'addons_meta' => [],
]);
return Service::create([
$this->service = Service::create([
'customer_id' => $this->customer->id,
'product_id' => $this->product->id,
'product_id' => $product->id,
'order_item_id' => $orderItem->id,
'type' => 'hosting',
'status' => 'active',
'server_name' => 'test-server',
'started_at' => now()->subMonth(),
'expires_at' => now()->addDays(5),
]);
}
private function createServiceWithCycle(string $billingCycle, int $cycleMonths, int $oldPrice): Service
private function renew(int $months)
{
$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',
]);
return $this->actingAs($this->customer, 'client')
->withHeaders(['x-access-token' => $this->buildClientAccessToken()])
->postJson("/v1/client/dich-vu/{$this->service->id}/renew", ['months' => $months]);
}
public function test_renew_annual_service_for_exactly_one_cycle_charges_the_annual_price(): void
public function test_renew_charges_balance_and_extends_expiry_on_the_same_order(): void
{
$service = $this->createServiceWithCycle('annually', 12, 1_800_000);
$expiresAt = $this->service->expires_at->copy();
ProductPricings::create([
'product_id' => $this->product->id,
'billing_cycle' => 'annually',
'price' => 1_800_000,
'is_active' => true,
]);
$this->renew(3)->assertOk()->assertJsonPath('success', 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,
]);
$invoice = Invoice::where('invoice_type', 2)->sole();
$this->assertSame($this->order->id, (int) $invoice->order_id);
$this->assertSame(InvoiceStatus::PAID->value, (int) $invoice->status);
$this->assertSame(45_000, (int) $invoice->total);
$this->assertSame(55_000, (int) $this->customer->fresh()->total);
$this->assertSame(1, Order::count());
$this->assertTrue($this->service->fresh()->expires_at->isSameDay($expiresAt->addMonths(3)));
$this->assertDatabaseHas('transactions', ['customer_id' => $this->customer->id, 'amount' => 45_000]);
}
public function test_renew_annual_service_with_months_not_matching_the_cycle_is_rejected(): void
public function test_insufficient_balance_keeps_unpaid_invoice_and_does_not_extend(): void
{
$service = $this->createServiceWithCycle('annually', 12, 1_800_000);
$this->customer->update(['total' => 10_000]);
$expiresAt = $this->service->expires_at->copy();
ProductPricings::create([
'product_id' => $this->product->id,
'billing_cycle' => 'annually',
'price' => 1_800_000,
'is_active' => true,
]);
$this->renew(1)->assertStatus(422)->assertJsonPath('data.status', InvoiceStatus::UNPAID->value);
$response = $this->actingAsCustomer()->postJson(
"/v1/client/dich-vu/{$service->id}/renew",
['months' => 1],
);
$this->assertSame(10_000, (int) $this->customer->fresh()->total);
$this->assertTrue($this->service->fresh()->expires_at->equalTo($expiresAt));
$response->assertStatus(422);
$this->assertSame(0, Invoice::where('customer_id', $this->customer->id)->count());
}
$this->customer->update(['total' => 100_000]);
$this->renew(2)->assertOk();
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());
$invoice = Invoice::where('invoice_type', 2)->sole();
$this->assertSame(InvoiceStatus::PAID->value, (int) $invoice->status);
$this->assertSame(30_000, (int) $invoice->total);
$this->assertSame(1, $invoice->invoiceItems()->count());
$this->assertSame(70_000, (int) $this->customer->fresh()->total);
}
}
@@ -0,0 +1,131 @@
<?php
declare(strict_types=1);
namespace Tests\Feature\Api\Transaction;
use App\Enums\TransactionStatus;
use App\Models\Customer;
use App\Models\Transaction;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Http\UploadedFile;
use Illuminate\Support\Facades\Storage;
use Tests\TestCase;
class ManualDepositDraftExpiryTest extends TestCase
{
use RefreshDatabase;
private function actingAsCustomer(Customer $customer): static
{
return $this->actingAs($customer, 'client')
->withHeaders(['x-access-token' => $this->buildClientAccessToken()]);
}
private function prepare(Customer $customer, int $amount = 500000): array
{
return $this->actingAsCustomer($customer)
->postJson('/v1/transaction/manual-deposit/prepare', ['amount' => $amount])
->assertOk()
->json('data');
}
public function test_prepare_returns_expiry_and_show_restores_draft(): void
{
$customer = Customer::factory()->create(['total_pending' => 0]);
$draft = $this->prepare($customer);
$this->assertSame(500000, $draft['amount']);
$this->assertEqualsWithDelta(now()->addMinutes(30)->timestamp, strtotime($draft['expires_at']), 5);
$this->actingAsCustomer($customer)
->getJson("/v1/transaction/manual-deposit/{$draft['uuid']}")
->assertOk()
->assertJsonPath('data.public_code', $draft['public_code'])
->assertJsonPath('data.amount', 500000);
}
public function test_show_hides_draft_of_another_customer(): void
{
$owner = Customer::factory()->create();
$other = Customer::factory()->create();
$draft = $this->prepare($owner);
$this->actingAsCustomer($other)
->getJson("/v1/transaction/manual-deposit/{$draft['uuid']}")
->assertNotFound();
}
public function test_expired_draft_is_discarded_on_show_and_rejected_on_confirm(): void
{
Storage::fake('local');
$customer = Customer::factory()->create(['total_pending' => 0]);
$first = $this->prepare($customer);
$this->travel(31)->minutes();
$this->actingAsCustomer($customer)
->getJson("/v1/transaction/manual-deposit/{$first['uuid']}")
->assertStatus(410);
$this->assertNull(Transaction::withTrashed()->where('uuid', $first['uuid'])->first());
$this->assertSame(0, (int) $customer->fresh()->total_pending);
$second = $this->prepare($customer);
$this->travel(31)->minutes();
$path = UploadedFile::fake()->image('proof.jpg')->store('deposit-proofs/' . $customer->id, 'local');
$this->actingAsCustomer($customer)
->postJson('/v1/transaction/manual-deposit/confirm', ['uuid' => $second['uuid'], 'proof_image' => $path])
->assertStatus(422);
$this->assertNull(Transaction::where('uuid', $second['uuid'])->value('proof_image'));
}
public function test_purge_command_removes_only_expired_drafts(): void
{
$customer = Customer::factory()->create(['total_pending' => 0]);
$expired = $this->prepare($customer, 300000);
$this->travel(31)->minutes();
$other = Customer::factory()->create(['total_pending' => 0]);
$fresh = $this->prepare($other, 200000);
$submitted = Transaction::create([
'amount' => 100000,
'customer_id' => $customer->id,
'type' => 'manual',
'status' => TransactionStatus::PENDING->value,
'proof_image' => 'deposit-proofs/x.jpg',
'trans' => 'MANTEST',
]);
$this->artisan('deposits:purge-expired-drafts')->assertSuccessful();
$this->assertNull(Transaction::withTrashed()->where('uuid', $expired['uuid'])->first());
$this->assertNotNull(Transaction::where('uuid', $fresh['uuid'])->first());
$this->assertNotNull($submitted->fresh());
$this->assertSame(0, (int) $customer->fresh()->total_pending);
$this->assertSame(0, (int) $other->fresh()->total_pending);
}
public function test_total_pending_only_counts_amount_after_proof_is_submitted(): void
{
Storage::fake('local');
$customer = Customer::factory()->create(['total_pending' => 0]);
$draft = $this->prepare($customer, 700000);
$this->assertSame(0, (int) $customer->fresh()->total_pending);
$path = $this->actingAsCustomer($customer)
->post('/v1/transaction/manual-deposit/upload-proof', ['file' => UploadedFile::fake()->image('proof.jpg')])
->assertOk()
->json('path');
$this->assertSame(0, (int) $customer->fresh()->total_pending);
$this->actingAsCustomer($customer)
->postJson('/v1/transaction/manual-deposit/confirm', ['uuid' => $draft['uuid'], 'proof_image' => $path])
->assertOk();
$this->assertSame(700000, (int) $customer->fresh()->total_pending);
}
}
@@ -0,0 +1,93 @@
<?php
declare(strict_types=1);
namespace Tests\Feature\Payment;
use App\Enums\TransactionStatus;
use App\Http\Controllers\Api\Admin\Transaction\AdminTransactionController;
use App\Models\Customer;
use App\Models\Transaction;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Mail;
use Illuminate\Support\Facades\Storage;
use Illuminate\Http\UploadedFile;
use Illuminate\Support\Str;
use Tests\TestCase;
class AdminManualDepositActionsTest extends TestCase
{
use RefreshDatabase;
private function deposit(Customer $customer, int $amount, ?string $proof, TransactionStatus $status = TransactionStatus::PENDING): Transaction
{
return Transaction::create([
'uuid' => (string) Str::uuid(),
'trans' => 'MANTEST' . Str::random(6),
'amount' => $amount,
'customer_id' => $customer->id,
'type' => 'manual',
'status' => $status->value,
'proof_image' => $proof,
]);
}
public function test_draft_without_proof_can_be_rejected_and_approved_without_touching_pending(): void
{
Mail::fake();
$customer = Customer::factory()->create(['total' => 0, 'total_pending' => 300_000]);
$controller = app(AdminTransactionController::class);
$rejected = $this->deposit($customer, 5_000_000, null);
$this->assertSame(200, $controller->reject($rejected->uuid)->getStatusCode());
$this->assertSame(TransactionStatus::FAILED->value, (int) $rejected->fresh()->status);
$approved = $this->deposit($customer, 1_000_000, null);
$this->assertSame(200, $controller->approve(new Request(), $approved->uuid)->getStatusCode());
$customer->refresh();
$this->assertSame(300_000, (int) $customer->total_pending);
$this->assertSame(1_000_000, (int) $customer->total);
}
public function test_signed_proof_image_url_serves_the_file(): void
{
Storage::fake('local');
$customer = Customer::factory()->create();
$path = UploadedFile::fake()->image('proof.png')->store('deposit-proofs/' . $customer->id, 'local');
$transaction = $this->deposit($customer, 300_000, $path);
$this->get($transaction->proof_image_urls[0])->assertOk();
$this->get(strtok($transaction->proof_image_urls[0], '?'))->assertForbidden();
}
public function test_deleting_submitted_pending_deposit_releases_pending_amount(): void
{
$customer = Customer::factory()->create(['total' => 0, 'total_pending' => 300_000]);
$transaction = $this->deposit($customer, 300_000, 'deposit-proofs/a.jpg');
$response = app(AdminTransactionController::class)->destroy($transaction->uuid);
$this->assertSame(200, $response->getStatusCode());
$this->assertSoftDeleted($transaction);
$this->assertSame(0, (int) $customer->fresh()->total_pending);
}
public function test_deleting_draft_or_completed_deposit_keeps_balances(): void
{
$customer = Customer::factory()->create(['total' => 2_000_000, 'total_pending' => 300_000]);
$controller = app(AdminTransactionController::class);
$draft = $this->deposit($customer, 500_000, null);
$completed = $this->deposit($customer, 2_000_000, 'deposit-proofs/b.jpg', TransactionStatus::COMPLETED);
$this->assertSame(200, $controller->destroy($draft->uuid)->getStatusCode());
$this->assertSame(200, $controller->destroy($completed->uuid)->getStatusCode());
$this->assertSame(404, $controller->destroy($completed->uuid)->getStatusCode());
$customer->refresh();
$this->assertSame(300_000, (int) $customer->total_pending);
$this->assertSame(2_000_000, (int) $customer->total);
}
}
@@ -30,6 +30,7 @@ class AdminTransactionApproveTest extends TestCase
'customer_id' => $customer->id,
'type' => 'manual',
'status' => TransactionStatus::PENDING->value,
'proof_image' => 'deposit-proofs/test.jpg',
]);
$controller = app(AdminTransactionController::class);
@@ -61,6 +62,7 @@ class AdminTransactionApproveTest extends TestCase
'customer_id' => $customer->id,
'type' => 'manual',
'status' => TransactionStatus::PENDING->value,
'proof_image' => 'deposit-proofs/test.jpg',
]);
$request = Request::create('/', 'POST', ['received_amount' => 4_000_000]);
@@ -92,6 +94,7 @@ class AdminTransactionApproveTest extends TestCase
'customer_id' => $customer->id,
'type' => 'manual',
'status' => TransactionStatus::PENDING->value,
'proof_image' => 'deposit-proofs/test.jpg',
]);
app(AdminTransactionController::class)->approve(new Request(), $transaction->uuid);