391 lines
16 KiB
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;
|
|
}
|
|
}
|