Add feature tests for domain order management
- Implement AdminDomainOrderTest to cover admin functionalities for domain orders including listing, confirming registration, KYC updates, activation, rejection, and refunds. - Implement ClientDomainOrderTest to cover client functionalities for placing domain orders, including validation for balance, duplicate orders, and domain normalization. - Ensure proper handling of domain pricing and service statuses throughout the tests.
This commit is contained in:
@@ -0,0 +1,413 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services\Api\Domain;
|
||||
|
||||
use App\Enums\Domain\DomainProvisioningStatus;
|
||||
use App\Enums\Invoice\InvoiceStatus;
|
||||
use App\Enums\Order\OrderStatus;
|
||||
use App\Enums\Product\ProductTypeEnums;
|
||||
use App\Enums\TransactionStatus;
|
||||
use App\Helpers\OrderHelper;
|
||||
use App\Models\Customer;
|
||||
use App\Models\DnsDomain;
|
||||
use App\Models\Invoice;
|
||||
use App\Models\InvoiceItem;
|
||||
use App\Models\Log as CustomerActivityLog;
|
||||
use App\Models\Order;
|
||||
use App\Models\OrderItem;
|
||||
use App\Models\Product;
|
||||
use App\Models\Service;
|
||||
use App\Models\Setting;
|
||||
use App\Models\Transaction;
|
||||
use App\Repositories\Api\Domain\DomainOrderRepository;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Str;
|
||||
|
||||
class DomainOrderService
|
||||
{
|
||||
public function __construct(
|
||||
private readonly DomainOrderRepository $repo,
|
||||
) {}
|
||||
|
||||
// =========================================================================
|
||||
// Client: Place domain order
|
||||
// =========================================================================
|
||||
|
||||
/**
|
||||
* @return array{order: Order, invoice: Invoice, service: Service}
|
||||
*/
|
||||
public function placeOrder(array $data, Customer $customer): array
|
||||
{
|
||||
$product = Product::where('uuid', $data['product_uuid'])
|
||||
->where('product_type', ProductTypeEnums::DOMAIN->value)
|
||||
->where('is_active', true)
|
||||
->whereNull('deleted_at')
|
||||
->firstOrFail();
|
||||
|
||||
$domainName = $this->normalizeDomain($data['domain_name']);
|
||||
$years = max(1, (int) ($data['years'] ?? 1));
|
||||
$pricing = $this->findDomainPricing($domainName);
|
||||
$price = (int) $pricing['firstYear'] * $years;
|
||||
$months = $years * 12;
|
||||
|
||||
return DB::transaction(function () use ($customer, $product, $domainName, $price, $months, $years) {
|
||||
$lockedCustomer = Customer::lockForUpdate()->find($customer->id);
|
||||
|
||||
if ((int) $lockedCustomer->total < $price) {
|
||||
throw new \InvalidArgumentException('Số dư không đủ để đặt mua tên miền. Vui lòng nạp thêm tiền vào tài khoản.');
|
||||
}
|
||||
|
||||
if ($this->repo->hasPendingOrActiveDomainByName($domainName)) {
|
||||
throw new \InvalidArgumentException('Tên miền đang có đơn xử lý hoặc đã được kích hoạt. Vui lòng chọn tên miền khác.');
|
||||
}
|
||||
|
||||
if (DnsDomain::where('domain_name', $domainName)->exists()) {
|
||||
throw new \InvalidArgumentException('Tên miền đã được đăng ký trong hệ thống. Vui lòng chọn tên miền khác.');
|
||||
}
|
||||
|
||||
$lockedCustomer->decrement('total', $price);
|
||||
|
||||
$order = Order::create([
|
||||
'order_num' => OrderHelper::generateOrderNumber(),
|
||||
'customer_id' => $lockedCustomer->id,
|
||||
'sub_total' => $price,
|
||||
'discount_amount' => 0,
|
||||
'total' => $price,
|
||||
'status' => OrderStatus::PROCESSING->value,
|
||||
'payment_method' => 'balance',
|
||||
'client_ip' => request()?->ip(),
|
||||
'note' => "Đăng ký tên miền: {$domainName} ({$years} năm)",
|
||||
]);
|
||||
|
||||
$orderItem = OrderItem::create([
|
||||
'order_id' => $order->id,
|
||||
'product_id' => $product->id,
|
||||
'domain_name' => $domainName,
|
||||
'billing_cycle' => 'yearly',
|
||||
'months' => $months,
|
||||
'quantity' => 1,
|
||||
'price' => $price,
|
||||
'server_name' => $domainName,
|
||||
]);
|
||||
|
||||
$invoice = Invoice::create([
|
||||
'invoice_number' => OrderHelper::generateOrderNumber('INV'),
|
||||
'customer_id' => $lockedCustomer->id,
|
||||
'order_id' => $order->id,
|
||||
'invoice_type' => 1, // new
|
||||
'sub_total' => $price,
|
||||
'discount_amount' => 0,
|
||||
'tax' => 0,
|
||||
'total' => $price,
|
||||
'status' => InvoiceStatus::PAID->value,
|
||||
'payment_method' => 'balance',
|
||||
'paid_at' => now(),
|
||||
'due_date' => now()->toDateString(),
|
||||
'client_ip' => request()?->ip(),
|
||||
]);
|
||||
|
||||
InvoiceItem::create([
|
||||
'invoice_id' => $invoice->id,
|
||||
'product_id' => $product->id,
|
||||
'type' => 'domain',
|
||||
'description' => "Đăng ký tên miền {$domainName} ({$years} năm)",
|
||||
'months' => $months,
|
||||
'quantity' => 1,
|
||||
'unit_price' => $price,
|
||||
'total_price' => $price,
|
||||
]);
|
||||
|
||||
Transaction::create([
|
||||
'uuid' => (string) Str::uuid(),
|
||||
'public_code' => 'DOM-' . now()->format('Ymd') . '-' . strtoupper(Str::random(6)),
|
||||
'trans' => $invoice->invoice_number,
|
||||
'amount' => $price,
|
||||
'customer_id' => $lockedCustomer->id,
|
||||
'status' => TransactionStatus::COMPLETED->value,
|
||||
'type' => 'invoice',
|
||||
'note' => "Thanh toán đăng ký tên miền {$domainName} ({$years} năm) - {$invoice->invoice_number} (balance)",
|
||||
]);
|
||||
|
||||
CustomerActivityLog::create([
|
||||
'agent' => 'api',
|
||||
'client_ip' => request()?->ip(),
|
||||
'customer_id' => $lockedCustomer->id,
|
||||
'description' => "Đặt mua tên miền {$domainName} ({$years} năm). Số tiền: " . number_format($price) . " VNĐ. Mã đơn hàng: {$order->order_num}.",
|
||||
]);
|
||||
|
||||
$service = Service::create([
|
||||
'customer_id' => $lockedCustomer->id,
|
||||
'product_id' => $product->id,
|
||||
'order_item_id' => $orderItem->id,
|
||||
'invoice_id' => $invoice->id,
|
||||
'server_name' => $domainName,
|
||||
'status' => DomainProvisioningStatus::PENDING->value,
|
||||
'auto_renew' => false,
|
||||
]);
|
||||
|
||||
return compact('order', 'invoice', 'service');
|
||||
});
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// Admin: Confirm registration → create DNS domain entry
|
||||
// =========================================================================
|
||||
|
||||
public function confirmRegistration(Order $order, array $data): Service
|
||||
{
|
||||
return DB::transaction(function () use ($order, $data) {
|
||||
$lockedOrder = Order::lockForUpdate()->find($order->id);
|
||||
|
||||
$orderItem = $lockedOrder->orderItems()
|
||||
->whereHas('product', fn ($q) => $q->where('product_type', ProductTypeEnums::DOMAIN->value))
|
||||
->with('service')
|
||||
->firstOrFail();
|
||||
|
||||
$service = $orderItem->service;
|
||||
|
||||
if (!$service || $service->status !== DomainProvisioningStatus::PENDING->value) {
|
||||
throw new \InvalidArgumentException('Đơn hàng không ở trạng thái chờ xử lý. Không thể xác nhận đăng ký.');
|
||||
}
|
||||
|
||||
$domainName = strtolower(trim($orderItem->domain_name));
|
||||
|
||||
if (DnsDomain::where('domain_name', $domainName)->exists()) {
|
||||
throw new \InvalidArgumentException("Tên miền {$domainName} đã tồn tại trong DNS Manager.");
|
||||
}
|
||||
|
||||
$dnsDomain = DnsDomain::create([
|
||||
'domain_name' => $domainName,
|
||||
'dns_manager_server_id' => $data['dns_manager_server_id'] ?? 0,
|
||||
'is_created' => 1,
|
||||
]);
|
||||
|
||||
$dnsDomain->customer()->syncWithoutDetaching([$lockedOrder->customer_id]);
|
||||
|
||||
$nextStatus = !empty($data['kyc_url'])
|
||||
? DomainProvisioningStatus::PENDING_KYC->value
|
||||
: DomainProvisioningStatus::REGISTRAR_CREATED->value;
|
||||
|
||||
$serviceUpdate = [
|
||||
'status' => $nextStatus,
|
||||
'registrar_reference' => $data['registrar_reference'] ?? null,
|
||||
'kyc_url' => $data['kyc_url'] ?? null,
|
||||
];
|
||||
|
||||
if (!empty($data['registered_at'])) {
|
||||
$serviceUpdate['started_at'] = $data['registered_at'];
|
||||
}
|
||||
|
||||
if (!empty($data['expires_at'])) {
|
||||
$serviceUpdate['expires_at'] = $data['expires_at'];
|
||||
}
|
||||
|
||||
$service->update($serviceUpdate);
|
||||
|
||||
return $service->fresh();
|
||||
});
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// Admin: Update KYC URL
|
||||
// =========================================================================
|
||||
|
||||
public function updateKycUrl(Order $order, string $kycUrl): Service
|
||||
{
|
||||
return DB::transaction(function () use ($order, $kycUrl) {
|
||||
$orderItem = $order->orderItems()
|
||||
->whereHas('product', fn ($q) => $q->where('product_type', ProductTypeEnums::DOMAIN->value))
|
||||
->with('service')
|
||||
->firstOrFail();
|
||||
|
||||
$service = $orderItem->service;
|
||||
|
||||
if (!$service) {
|
||||
throw new \InvalidArgumentException('Không tìm thấy dịch vụ cho đơn hàng này.');
|
||||
}
|
||||
|
||||
$allowedStatuses = [
|
||||
DomainProvisioningStatus::REGISTRAR_CREATED->value,
|
||||
DomainProvisioningStatus::PENDING_KYC->value,
|
||||
];
|
||||
|
||||
if (!in_array($service->status, $allowedStatuses, true)) {
|
||||
throw new \InvalidArgumentException('Chỉ có thể cập nhật KYC khi tên miền đã được tạo tại nhà đăng ký hoặc đang chờ KYC.');
|
||||
}
|
||||
|
||||
$service->update([
|
||||
'kyc_url' => $kycUrl,
|
||||
'status' => DomainProvisioningStatus::PENDING_KYC->value,
|
||||
]);
|
||||
|
||||
return $service->fresh();
|
||||
});
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// Admin: Activate domain service
|
||||
// =========================================================================
|
||||
|
||||
public function activate(Order $order): Service
|
||||
{
|
||||
return DB::transaction(function () use ($order) {
|
||||
$orderItem = $order->orderItems()
|
||||
->whereHas('product', fn ($q) => $q->where('product_type', ProductTypeEnums::DOMAIN->value))
|
||||
->with('service')
|
||||
->firstOrFail();
|
||||
|
||||
$service = $orderItem->service;
|
||||
|
||||
if (!$service) {
|
||||
throw new \InvalidArgumentException('Không tìm thấy dịch vụ cho đơn hàng này.');
|
||||
}
|
||||
|
||||
$activatableStatuses = [
|
||||
DomainProvisioningStatus::REGISTRAR_CREATED->value,
|
||||
DomainProvisioningStatus::PENDING_KYC->value,
|
||||
];
|
||||
|
||||
if (!in_array($service->status, $activatableStatuses, true)) {
|
||||
throw new \InvalidArgumentException('Chỉ có thể kích hoạt tên miền sau khi đã tạo tại nhà đăng ký (hoặc sau khi KYC).');
|
||||
}
|
||||
|
||||
$service->update([
|
||||
'status' => DomainProvisioningStatus::ACTIVE->value,
|
||||
'started_at' => $service->started_at ?? now(),
|
||||
]);
|
||||
|
||||
$order->update(['status' => OrderStatus::ACTIVE->value]);
|
||||
|
||||
return $service->fresh();
|
||||
});
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// Admin: Reject and refund
|
||||
// =========================================================================
|
||||
|
||||
public function rejectAndRefund(Order $order, string $reason): Service
|
||||
{
|
||||
return DB::transaction(function () use ($order, $reason) {
|
||||
$lockedOrder = Order::lockForUpdate()->find($order->id);
|
||||
|
||||
$orderItem = $lockedOrder->orderItems()
|
||||
->whereHas('product', fn ($q) => $q->where('product_type', ProductTypeEnums::DOMAIN->value))
|
||||
->with('service')
|
||||
->firstOrFail();
|
||||
|
||||
$service = $orderItem->service;
|
||||
|
||||
if (!$service || $service->status !== DomainProvisioningStatus::PENDING->value) {
|
||||
throw new \InvalidArgumentException('Chỉ có thể từ chối đơn hàng đang chờ xử lý.');
|
||||
}
|
||||
|
||||
// Anti-double-refund guard
|
||||
if ($service->refunded_at !== null) {
|
||||
throw new \InvalidArgumentException('Đơn hàng này đã được hoàn tiền trước đó.');
|
||||
}
|
||||
|
||||
$invoice = $lockedOrder->invoice;
|
||||
|
||||
if (!$invoice || (int) $invoice->status !== InvoiceStatus::PAID->value) {
|
||||
throw new \InvalidArgumentException('Không tìm thấy hóa đơn đã thanh toán.');
|
||||
}
|
||||
|
||||
$refundAmount = (int) $invoice->total; // snapshot — NOT recalculated
|
||||
|
||||
$lockedCustomer = Customer::lockForUpdate()->find($lockedOrder->customer_id);
|
||||
|
||||
if (!$lockedCustomer) {
|
||||
throw new \InvalidArgumentException('Không tìm thấy khách hàng.');
|
||||
}
|
||||
|
||||
$lockedCustomer->increment('total', $refundAmount);
|
||||
|
||||
$invoice->update(['status' => InvoiceStatus::REFUNDED->value]);
|
||||
$lockedOrder->update(['status' => OrderStatus::CANCELLED->value]);
|
||||
|
||||
$service->update([
|
||||
'status' => DomainProvisioningStatus::REFUNDED->value,
|
||||
'rejection_reason' => $reason,
|
||||
'refunded_at' => now(),
|
||||
'cancelled_at' => now(),
|
||||
]);
|
||||
|
||||
Transaction::create([
|
||||
'uuid' => (string) Str::uuid(),
|
||||
'public_code' => 'DOM-REF-' . now()->format('Ymd') . '-' . strtoupper(Str::random(6)),
|
||||
'trans' => 'REF-' . $invoice->invoice_number,
|
||||
'amount' => $refundAmount,
|
||||
'customer_id' => $lockedCustomer->id,
|
||||
'status' => TransactionStatus::COMPLETED->value,
|
||||
'type' => 'refund',
|
||||
'note' => "Hoàn tiền đăng ký tên miền {$orderItem->domain_name}: {$reason} (balance)",
|
||||
]);
|
||||
|
||||
CustomerActivityLog::create([
|
||||
'agent' => 'api',
|
||||
'client_ip' => request()?->ip(),
|
||||
'customer_id' => $lockedCustomer->id,
|
||||
'description' => "Hoàn tiền đăng ký tên miền {$orderItem->domain_name}. Số tiền: " . number_format($refundAmount) . " VNĐ. Lý do: {$reason}.",
|
||||
]);
|
||||
|
||||
return $service->fresh();
|
||||
});
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// Private helpers
|
||||
// =========================================================================
|
||||
|
||||
/**
|
||||
* Find pricing for the given domain using longest-match TLD.
|
||||
* TLDs in settings are stored with a leading dot (e.g. ".com.vn").
|
||||
*
|
||||
* @throws \InvalidArgumentException if no matching TLD found
|
||||
*/
|
||||
private function findDomainPricing(string $domain): array
|
||||
{
|
||||
$setting = Setting::where('key', 'domain_pricing')->first();
|
||||
|
||||
if (!$setting || empty($setting->value)) {
|
||||
throw new \InvalidArgumentException('Không tìm thấy cấu hình giá tên miền.');
|
||||
}
|
||||
|
||||
$pricingData = is_string($setting->value)
|
||||
? json_decode($setting->value, true)
|
||||
: (array) $setting->value;
|
||||
|
||||
$domains = $pricingData['domains'] ?? [];
|
||||
|
||||
// Longest-match: sort TLDs by length descending so ".com.vn" beats ".vn"
|
||||
usort($domains, fn ($a, $b) => strlen($b['tld']) - strlen($a['tld']));
|
||||
|
||||
foreach ($domains as $entry) {
|
||||
$tld = ltrim($entry['tld'], '.');
|
||||
if (str_ends_with($domain, '.' . $tld)) {
|
||||
return $entry;
|
||||
}
|
||||
}
|
||||
|
||||
throw new \InvalidArgumentException("Tên miền không được hỗ trợ. Vui lòng chọn đuôi tên miền có trong danh sách.");
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalize: lowercase, trim, strip protocol and path.
|
||||
* "HTTPS://My-Site.COM.VN/path" → "my-site.com.vn"
|
||||
*/
|
||||
private function normalizeDomain(string $domain): string
|
||||
{
|
||||
$domain = strtolower(trim($domain));
|
||||
|
||||
// Strip protocol
|
||||
$domain = preg_replace('#^https?://#', '', $domain);
|
||||
|
||||
// Strip path/query after first slash
|
||||
$domain = explode('/', $domain)[0];
|
||||
|
||||
// Strip www. prefix
|
||||
$domain = preg_replace('#^www\.#', '', $domain);
|
||||
|
||||
return $domain;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user