Files
portal-api/app/Services/Api/Domain/DomainOrderService.php

391 lines
16 KiB
PHP

<?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\DomainPricing;
use App\Models\Order;
use App\Models\OrderItem;
use App\Models\Product;
use App\Models\Service;
use App\Models\Transaction;
use App\Repositories\Api\Domain\DomainOrderRepository;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Str;
use App\Support\VatNumbering;
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));
$price = DomainPricing::findByDomain($domainName)->orderPrice($years);
$months = $years * 12;
return DB::transaction(function () use ($customer, $product, $domainName, $price, $months, $years) {
$lockedCustomer = Customer::lockForUpdate()->find($customer->id);
if (!$lockedCustomer->front_identity_number || !$lockedCustomer->back_identity_number) {
throw new \InvalidArgumentException('Vui lòng bổ sung ảnh CMND/CCCD (2 mặt) trước khi đặt mua tên miền.');
}
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,
]);
VatNumbering::settle($invoice);
Transaction::create([
'uuid' => (string) Str::uuid(),
'public_code' => Transaction::newPublicCode('DOM'),
'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,
'type' => ProductTypeEnums::DOMAIN->value,
'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).');
}
$startedAt = $service->started_at ?? now();
$service->update([
'status' => DomainProvisioningStatus::ACTIVE->value,
'started_at' => $startedAt,
'expires_at' => $service->expires_at ?? $startedAt->copy()->addMonths((int) $orderItem->months),
]);
$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' => Transaction::newPublicCode('DOM-REF'),
'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
// =========================================================================
/**
* 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;
}
}