168 lines
8.0 KiB
PHP
168 lines
8.0 KiB
PHP
<?php
|
|
|
|
declare(strict_types=1);
|
|
|
|
namespace Tests\Feature\Api\Admin\Billing;
|
|
|
|
use App\Models\Customer;
|
|
use App\Models\Invoice;
|
|
use App\Models\InvoiceItem;
|
|
use App\Models\Order;
|
|
use App\Models\OrderItem;
|
|
use App\Models\Product;
|
|
use App\Models\User;
|
|
use App\Support\Billing\BillingDuplicateDetector as Detector;
|
|
use Illuminate\Foundation\Testing\RefreshDatabase;
|
|
use Spatie\Permission\Models\Role;
|
|
use Tests\TestCase;
|
|
|
|
class BillingDuplicateDetectorTest extends TestCase
|
|
{
|
|
use RefreshDatabase;
|
|
|
|
protected function setUp(): void
|
|
{
|
|
parent::setUp();
|
|
Detector::flush();
|
|
}
|
|
|
|
private function order(Customer $customer, Product $product, string $name, int $total = 100_000, string $at = 'now', int $status = 0, string $domain = ''): Order
|
|
{
|
|
$order = Order::create([
|
|
'order_num' => 'ORD-' . uniqid(), 'customer_id' => $customer->id, 'sub_total' => $total, 'total' => $total,
|
|
'status' => $status, 'created_at' => $at, 'updated_at' => $at,
|
|
]);
|
|
OrderItem::create([
|
|
'order_id' => $order->id, 'product_id' => $product->id, 'billing_cycle' => 'monthly', 'months' => 1,
|
|
'quantity' => 1, 'price' => $total, 'server_name' => $name, 'domain_name' => $domain ?: null,
|
|
]);
|
|
|
|
return $order;
|
|
}
|
|
|
|
private function invoice(Customer $customer, ?Order $order, int $type, int $total, string $at = 'now', ?string $period = null, string $item = 'VPS'): Invoice
|
|
{
|
|
$invoice = Invoice::create([
|
|
'invoice_number' => 'INV-' . uniqid(), 'customer_id' => $customer->id, 'order_id' => $order?->id, 'invoice_type' => $type,
|
|
'sub_total' => $total, 'tax' => 0, 'total' => $total, 'status' => 1, 'billing_period_start' => $period,
|
|
'created_at' => $at, 'updated_at' => $at,
|
|
]);
|
|
InvoiceItem::create(['invoice_id' => $invoice->id, 'type' => 'service', 'description' => $item, 'months' => 1, 'quantity' => 1, 'unit_price' => $total, 'total_price' => $total]);
|
|
|
|
return $invoice;
|
|
}
|
|
|
|
public function test_same_customer_product_and_service_name_is_flagged_even_days_apart(): void
|
|
{
|
|
$customer = Customer::factory()->create();
|
|
$product = Product::factory()->create();
|
|
$a = $this->order($customer, $product, 'Web-01', at: '2026-10-01 10:00:00');
|
|
$b = $this->order($customer, $product, ' web-01 ', at: '2026-10-09 10:00:00');
|
|
$other = $this->order($customer, $product, 'web-02');
|
|
|
|
$found = Detector::orders();
|
|
|
|
$this->assertSame(Detector::REASON_SAME_SERVICE, $found[$a->id]['reason']);
|
|
$this->assertSame([$a->order_num], $found[$b->id]['with']);
|
|
$this->assertArrayNotHasKey($other->id, $found);
|
|
}
|
|
|
|
public function test_rapid_resubmit_needs_same_items_and_window(): void
|
|
{
|
|
$customer = Customer::factory()->create();
|
|
$product = Product::factory()->create();
|
|
$a = $this->order($customer, $product, '', at: '2026-10-01 10:00:00');
|
|
$b = $this->order($customer, $product, '', at: '2026-10-01 11:00:00');
|
|
$late = $this->order($customer, $product, '', at: '2026-10-05 11:00:00');
|
|
$differentTotal = $this->order($customer, $product, '', total: 200_000, at: '2026-10-01 10:30:00');
|
|
|
|
$found = Detector::orders();
|
|
|
|
$this->assertSame(Detector::REASON_RESUBMIT, $found[$a->id]['reason']);
|
|
$this->assertSame([$a->order_num], $found[$b->id]['with']);
|
|
$this->assertArrayNotHasKey($late->id, $found);
|
|
$this->assertArrayNotHasKey($differentTotal->id, $found);
|
|
}
|
|
|
|
public function test_domain_ordered_by_two_customers_is_flagged_but_cancelled_orders_are_ignored(): void
|
|
{
|
|
$product = Product::factory()->create(['product_type' => 'domain']);
|
|
$first = $this->order(Customer::factory()->create(), $product, 'shop.vn', domain: 'Shop.vn');
|
|
$second = $this->order(Customer::factory()->create(), $product, 'shop.vn', domain: 'shop.vn');
|
|
$cancelled = $this->order(Customer::factory()->create(), $product, 'shop.vn', status: 2, domain: 'shop.vn');
|
|
|
|
$found = Detector::orders();
|
|
|
|
$this->assertSame(Detector::REASON_DOMAIN_TAKEN, $found[$first->id]['reason']);
|
|
$this->assertSame(Detector::REASON_DOMAIN_TAKEN, $found[$second->id]['reason']);
|
|
$this->assertArrayNotHasKey($cancelled->id, $found);
|
|
}
|
|
|
|
public function test_invoices_same_order_and_type_are_flagged_but_renewal_periods_and_upgrades_are_not(): void
|
|
{
|
|
$customer = Customer::factory()->create();
|
|
$order = $this->order($customer, Product::factory()->create(), 'vm-1');
|
|
$a = $this->invoice($customer, $order, 1, 100_000);
|
|
$b = $this->invoice($customer, $order, 1, 150_000);
|
|
$renewal = $this->invoice($customer, $order, 2, 100_000, period: '2026-11-01');
|
|
$next = $this->invoice($customer, $order, 2, 100_000, period: '2026-12-01');
|
|
$upgradeA = $this->invoice($customer, $order, 3, 50_000, at: '2026-10-01 10:00:00', item: 'Addon RAM');
|
|
$upgradeB = $this->invoice($customer, $order, 3, 70_000, at: '2026-10-01 10:30:00', item: 'Addon CPU');
|
|
|
|
$found = Detector::invoices();
|
|
|
|
$this->assertSame(Detector::REASON_SAME_PERIOD, $found[$a->id]['reason']);
|
|
$this->assertSame([$a->invoice_number], $found[$b->id]['with']);
|
|
$this->assertArrayNotHasKey($renewal->id, $found);
|
|
$this->assertArrayNotHasKey($next->id, $found);
|
|
$this->assertArrayNotHasKey($upgradeA->id, $found);
|
|
$this->assertArrayNotHasKey($upgradeB->id, $found);
|
|
}
|
|
|
|
public function test_invoice_rapid_resubmit_requires_identical_items(): void
|
|
{
|
|
$customer = Customer::factory()->create();
|
|
$a = $this->invoice($customer, null, 1, 100_000, at: '2026-10-01 10:00:00');
|
|
$b = $this->invoice($customer, null, 1, 100_000, at: '2026-10-01 10:05:00');
|
|
$diff = $this->invoice($customer, null, 1, 100_000, at: '2026-10-01 10:06:00', item: 'Khác');
|
|
|
|
$found = Detector::invoices();
|
|
|
|
$this->assertSame(Detector::REASON_RESUBMIT, $found[$a->id]['reason']);
|
|
$this->assertSame([$a->invoice_number], $found[$b->id]['with']);
|
|
$this->assertArrayNotHasKey($diff->id, $found);
|
|
}
|
|
|
|
public function test_list_endpoints_expose_flag_and_filter(): void
|
|
{
|
|
Role::firstOrCreate(['name' => 'Full Administrator', 'guard_name' => 'admin']);
|
|
$admin = tap(User::factory()->create())->assignRole('Full Administrator');
|
|
$customer = Customer::factory()->create();
|
|
$product = Product::factory()->create();
|
|
$a = $this->order($customer, $product, 'dup-1');
|
|
$b = $this->order($customer, $product, 'dup-1');
|
|
$solo = $this->order($customer, $product, 'solo');
|
|
$inv = $this->invoice($customer, $a, 1, 100_000);
|
|
$this->invoice($customer, $a, 1, 100_000);
|
|
|
|
$orders = $this->actingAs($admin, 'admin')->getJson('/api/v1/administrator/orders?duplicates=1')->assertOk();
|
|
$this->assertEqualsCanonicalizing([$a->id, $b->id], collect($orders->json('data.items'))->pluck('id')->all());
|
|
$this->assertSame(Detector::REASON_RESUBMIT, $orders->json('data.items.0.duplicate.reason'));
|
|
|
|
$all = $this->actingAs($admin, 'admin')->getJson('/api/v1/administrator/orders')->assertOk();
|
|
$this->assertCount(3, $all->json('data.items'));
|
|
$this->assertNull(collect($all->json('data.items'))->firstWhere('id', $solo->id)['duplicate']);
|
|
|
|
$invoices = $this->actingAs($admin, 'admin')->getJson('/api/v1/administrator/invoices?duplicates=1')->assertOk();
|
|
$this->assertCount(2, $invoices->json('data.items'));
|
|
$this->assertSame(Detector::REASON_SAME_PERIOD, collect($invoices->json('data.items'))->firstWhere('id', $inv->id)['duplicate']['reason']);
|
|
}
|
|
|
|
public function test_order_date_defaults_to_today_when_not_given(): void
|
|
{
|
|
$order = Order::create(['order_num' => 'ORD-DATE', 'customer_id' => Customer::factory()->create()->id, 'sub_total' => 1, 'total' => 1, 'status' => 0]);
|
|
|
|
$this->assertSame(now()->toDateString(), $order->fresh()->date->toDateString());
|
|
}
|
|
}
|