diff --git a/app/Http/Controllers/Api/Admin/Customer/Concerns/ListsCustomerEmails.php b/app/Http/Controllers/Api/Admin/Customer/Concerns/ListsCustomerEmails.php
index c82ffe2..298fbf9 100644
--- a/app/Http/Controllers/Api/Admin/Customer/Concerns/ListsCustomerEmails.php
+++ b/app/Http/Controllers/Api/Admin/Customer/Concerns/ListsCustomerEmails.php
@@ -16,6 +16,7 @@ trait ListsCustomerEmails
$paginator = Mail::query()
->with(['sender', 'template:id,name'])
->where('customer_id', $customer->id)
+ ->when($request->filled('order_id'), fn ($q) => $q->where('order_id', (int) $request->get('order_id')))
->when($search !== '', fn ($q) => $q->where(fn ($q) => $q
->where('subject', 'like', "%{$search}%")
->orWhere('to', 'like', "%{$search}%")))
diff --git a/app/Http/Controllers/Api/Admin/Customer/Concerns/ManagesCustomerBilling.php b/app/Http/Controllers/Api/Admin/Customer/Concerns/ManagesCustomerBilling.php
index 7411f73..39eb465 100644
--- a/app/Http/Controllers/Api/Admin/Customer/Concerns/ManagesCustomerBilling.php
+++ b/app/Http/Controllers/Api/Admin/Customer/Concerns/ManagesCustomerBilling.php
@@ -51,6 +51,27 @@ trait ManagesCustomerBilling
return response()->json(['error' => false, 'message' => 'Đã cập nhật giao dịch', 'data' => $transaction]);
}
+ public function createTransaction(string $uuid, Request $request): JsonResponse
+ {
+ $data = $request->validate([
+ 'trans' => 'nullable|string|max:255',
+ 'note' => 'nullable|string',
+ 'amount' => 'required|integer|min:0',
+ 'status' => 'nullable|integer|in:0,1,2',
+ ]);
+
+ $transaction = $this->billingService->createTransaction($uuid, $data, $request->user()?->id);
+
+ return response()->json(['error' => false, 'message' => 'Đã thêm giao dịch', 'data' => $transaction], 201);
+ }
+
+ public function deleteTransaction(string $uuid, int $transactionId): JsonResponse
+ {
+ $this->billingService->deleteTransaction($uuid, $transactionId);
+
+ return response()->json(['error' => false, 'message' => 'Đã xoá giao dịch']);
+ }
+
public function getOrders(string $uuid): JsonResponse
{
return response()->json(['error' => false, 'data' => $this->billingService->getOrders($uuid)]);
@@ -66,6 +87,7 @@ trait ManagesCustomerBilling
'sub_total' => 'nullable|integer|min:0',
'discount_amount' => 'nullable|integer|min:0',
'total' => 'nullable|integer|min:0',
+ 'note' => 'nullable|string|max:5000',
]);
$order = $this->billingService->updateOrder($uuid, $orderId, $data);
diff --git a/app/Http/Controllers/Api/Admin/DnsManager/DomainController.php b/app/Http/Controllers/Api/Admin/DnsManager/DomainController.php
index 65c961c..8232f80 100644
--- a/app/Http/Controllers/Api/Admin/DnsManager/DomainController.php
+++ b/app/Http/Controllers/Api/Admin/DnsManager/DomainController.php
@@ -3,6 +3,9 @@
namespace App\Http\Controllers\Api\Admin\DnsManager;
use App\Http\Controllers\Controller;
+use App\Jobs\DnsManager\ProvisionDnsDomainJob;
+use App\Jobs\DnsManager\SuspendDnsDomainJob;
+use App\Models\DnsDomain;
use App\Jobs\DnsManager\SyncDnsFromPlesk;
use App\Repositories\DnsManager\Domain\DnsDomainRepositoryInterface;
use App\Repositories\DnsManager\Record\DnsRecordRepositoryInterface;
@@ -97,7 +100,7 @@ class DomainController extends Controller
'dns_manager_server_id' => 'required|integer|exists:dns_manager_servers,id',
'customer_ids' => 'nullable|array',
'customer_ids.*' => 'integer|exists:customers,id',
- 'is_created' => 'nullable|integer|in:0,1,2',
+ 'is_created' => 'nullable|integer|in:0,1,2,3',
'registrar_id' => 'nullable|integer|exists:registrars,id',
]);
@@ -116,9 +119,14 @@ class DomainController extends Controller
DB::commit();
+ $queued = $this->queueProvisioning($domain->fresh());
+
return response()->json([
- 'error' => false,
- 'data' => ['id' => $domain->id, 'domain_name' => $domain->domain_name],
+ 'error' => false,
+ 'message' => $queued
+ ? "Đã lưu domain {$domain->domain_name}, đang khởi tạo trên server (queue)."
+ : "Đã lưu domain {$domain->domain_name}.",
+ 'data' => ['id' => $domain->id, 'domain_name' => $domain->domain_name, 'provisioning_queued' => $queued],
], 201);
} catch (\Exception $e) {
DB::rollBack();
@@ -139,7 +147,7 @@ class DomainController extends Controller
'dns_manager_server_id' => 'nullable|integer|exists:dns_manager_servers,id',
'customer_ids' => 'nullable|array',
'customer_ids.*' => 'integer|exists:customers,id',
- 'is_created' => 'nullable|integer|in:0,1,2',
+ 'is_created' => 'nullable|integer|in:0,1,2,3',
'registrar_id' => 'nullable|integer|exists:registrars,id',
]);
@@ -159,6 +167,8 @@ class DomainController extends Controller
DB::commit();
+ $this->queueProvisioning($domain->fresh());
+
return $this->show($id);
} catch (\Exception $e) {
DB::rollBack();
@@ -166,6 +176,30 @@ class DomainController extends Controller
}
}
+ public function provision(int $id): JsonResponse
+ {
+ $domain = $this->domainRepository->find($id);
+
+ if (!$domain) {
+ return response()->json(['error' => true, 'message' => "Không tìm thấy domain #$id"], 404);
+ }
+
+ if ($domain->plesk_id) {
+ return response()->json(['error' => true, 'message' => "Domain {$domain->domain_name} đã được khởi tạo trên Plesk."], 422);
+ }
+
+ if ((int) $domain->is_created === DnsDomain::STATUS_CANCELLED) {
+ return response()->json(['error' => true, 'message' => "Domain {$domain->domain_name} đã huỷ, không thể khởi tạo."], 422);
+ }
+
+ ProvisionDnsDomainJob::dispatch($domain->id);
+
+ return response()->json([
+ 'error' => false,
+ 'message' => "Đã đưa {$domain->domain_name} vào queue khởi tạo trên Plesk.",
+ ]);
+ }
+
public function destroy(int $id): JsonResponse
{
$domain = $this->domainRepository->find($id);
@@ -174,24 +208,40 @@ class DomainController extends Controller
return response()->json(['error' => true, 'message' => "Không tìm thấy domain #$id"], 404);
}
- DB::beginTransaction();
- try {
- $domain->customer()->detach();
- $domain->record()->delete();
- $this->domainRepository->delete($id);
- DB::commit();
-
- return response()->json(['error' => false, 'message' => 'Đã xoá domain thành công']);
- } catch (\Exception $e) {
- DB::rollBack();
- return response()->json(['error' => true, 'message' => $e->getMessage()], 500);
+ if ((int) $domain->is_created === DnsDomain::STATUS_CANCELLED) {
+ return response()->json(['error' => false, 'message' => "Domain {$domain->domain_name} đã được huỷ trước đó."]);
}
+
+ $onPlesk = (bool) $domain->plesk_id;
+ $domain->update(['is_created' => DnsDomain::STATUS_CANCELLED]);
+
+ if ($onPlesk) {
+ SuspendDnsDomainJob::dispatch($domain->id);
+ }
+
+ return response()->json([
+ 'error' => false,
+ 'message' => $onPlesk
+ ? "Đã huỷ domain {$domain->domain_name}, đang suspend trên server (queue)."
+ : "Đã huỷ domain {$domain->domain_name}.",
+ ]);
}
// ─── DNS Records ─────────────────────────────────────────────────────────
// ─── Helpers ─────────────────────────────────────────────────────────────
+ private function queueProvisioning($domain): bool
+ {
+ if ($domain->plesk_id || in_array((int) $domain->is_created, [DnsDomain::STATUS_INACTIVE, DnsDomain::STATUS_CANCELLED], true)) {
+ return false;
+ }
+
+ ProvisionDnsDomainJob::dispatch($domain->id);
+
+ return true;
+ }
+
private function formatDomain($domain): array
{
return [
@@ -206,6 +256,7 @@ class DomainController extends Controller
'email' => $c->email,
])->values()->toArray(),
'is_created' => $domain->is_created,
+ 'plesk_id' => $domain->plesk_id,
'registrar_id' => $domain->registrar_id,
'registrar' => $domain->registrar ? [
'id' => $domain->registrar->id,
diff --git a/app/Http/Controllers/Api/Admin/Invoice/Concerns/HandlesInvoiceAttachments.php b/app/Http/Controllers/Api/Admin/Invoice/Concerns/HandlesInvoiceAttachments.php
index 6c1f6a4..bac587e 100644
--- a/app/Http/Controllers/Api/Admin/Invoice/Concerns/HandlesInvoiceAttachments.php
+++ b/app/Http/Controllers/Api/Admin/Invoice/Concerns/HandlesInvoiceAttachments.php
@@ -2,6 +2,7 @@
namespace App\Http\Controllers\Api\Admin\Invoice\Concerns;
+use App\Models\InvoiceAttachment;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
@@ -30,7 +31,8 @@ trait HandlesInvoiceAttachments
}
$request->validate([
- 'file' => 'required|file|mimes:jpg,jpeg,png,gif,pdf,doc,docx,xls,xlsx|max:10240',
+ 'file' => 'required|file|mimes:jpg,jpeg,png,gif,webp,pdf,doc,docx,xls,xlsx|max:51200',
+ 'type' => 'nullable|in:' . InvoiceAttachment::TYPE_PAYMENT_PROOF . ',' . InvoiceAttachment::TYPE_DEPLOYMENT,
]);
$file = $request->file('file');
@@ -39,6 +41,7 @@ trait HandlesInvoiceAttachments
$attachment = $this->attachmentRepository->create([
'invoice_id' => $id,
+ 'type' => $request->input('type', InvoiceAttachment::TYPE_PAYMENT_PROOF),
'filename' => $file->getClientOriginalName(),
'path' => $path,
'disk' => 'public',
diff --git a/app/Http/Controllers/Api/Admin/Invoice/Concerns/SendsInvoiceNotifications.php b/app/Http/Controllers/Api/Admin/Invoice/Concerns/SendsInvoiceNotifications.php
index 3445ceb..c724dbc 100644
--- a/app/Http/Controllers/Api/Admin/Invoice/Concerns/SendsInvoiceNotifications.php
+++ b/app/Http/Controllers/Api/Admin/Invoice/Concerns/SendsInvoiceNotifications.php
@@ -2,15 +2,15 @@
namespace App\Http\Controllers\Api\Admin\Invoice\Concerns;
+use App\Jobs\Mail\SendInvoiceMailJob;
use App\Mail\InvoiceNotificationMail;
-use App\Models\Customer;
+use App\Models\Invoice;
use App\Services\InvoiceReminderService;
use App\Support\AutoEmailGate;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
-use Illuminate\Support\Facades\Log;
-use Illuminate\Support\Facades\Mail;
-use Throwable;
+use Illuminate\Support\Facades\Auth;
+use Illuminate\Support\Facades\Queue;
trait SendsInvoiceNotifications
{
@@ -29,6 +29,10 @@ trait SendsInvoiceNotifications
], 404);
}
+ if ($disabled = $this->mailDisabledResponse()) {
+ return $disabled;
+ }
+
if (!$reminders->isRemindable($invoice)) {
return response()->json([
'error' => true,
@@ -52,11 +56,69 @@ trait SendsInvoiceNotifications
]);
}
+ public function previewMail(Request $request, int $id): JsonResponse
+ {
+ $invoice = $this->invoiceForMail($request, $id);
+ if ($invoice instanceof JsonResponse) {
+ return $invoice;
+ }
+
+ $mail = InvoiceNotificationMail::forInvoice($invoice, $request->input('type') ?: 'invoice', $request->input('message'));
+
+ return response()->json([
+ 'error' => false,
+ 'data' => [
+ 'to' => $invoice->customer->email,
+ 'subject' => $mail->envelope()->subject,
+ 'html' => $mail->render(),
+ 'attachment_filename' => $this->invoicePdfService->generateFileName($invoice),
+ ],
+ ]);
+ }
+
public function sendMail(Request $request, int $id): JsonResponse
+ {
+ if ($disabled = $this->mailDisabledResponse()) {
+ return $disabled;
+ }
+
+ $invoice = $this->invoiceForMail($request, $id);
+ if ($invoice instanceof JsonResponse) {
+ return $invoice;
+ }
+
+ AutoEmailGate::markManual();
+ $jobId = Queue::push(new SendInvoiceMailJob(
+ $invoice->id,
+ $request->input('type') ?: 'invoice',
+ $request->input('message'),
+ $request->input('subject'),
+ $request->filled('html') ? $request->input('html') : null,
+ Auth::guard('admin')->id(),
+ ));
+
+ return response()->json([
+ 'error' => false,
+ 'message' => "Đã đưa email vào hàng đợi gửi tới {$invoice->customer->email}",
+ 'data' => ['job_id' => $jobId],
+ ]);
+ }
+
+ private function mailDisabledResponse(): ?JsonResponse
+ {
+ return AutoEmailGate::mailEnabled() ? null : response()->json([
+ 'error' => true,
+ 'message' => 'Chức năng gửi email đang tắt. Bật lại tại Cài đặt → Thông báo.',
+ ], 422);
+ }
+
+ private function invoiceForMail(Request $request, int $id): Invoice|JsonResponse
{
$request->validate([
'message' => 'nullable|string|max:1000',
'type' => 'nullable|string|in:invoice,renewal',
+ 'subject' => 'nullable|required_with:html|string|max:255',
+ 'html' => 'nullable|required_with:subject|string|max:500000',
]);
$invoice = $this->invoiceRepository->find($id);
@@ -65,57 +127,10 @@ trait SendsInvoiceNotifications
}
$invoice->load('customer');
- $customer = $invoice->customer;
- if (!$customer || !$customer->email) {
+ if (!$invoice->customer?->email) {
return response()->json(['error' => true, 'message' => 'Customer email not found'], 422);
}
- $attachments = $this->attachmentRepository->getByInvoice($id);
- $companyName = setting('general.company_name') ?: config('app.name');
- $type = $request->get('type', 'invoice');
- $message = $request->get(
- 'message',
- $type === 'renewal'
- ? "Dịch vụ của quý khách sắp đến hạn gia hạn. Vui lòng kiểm tra hóa đơn #{$invoice->order_num} được đính kèm và hoàn tất thanh toán để dịch vụ không bị gián đoạn."
- : "Vui lòng xem thông tin hóa đơn #{$invoice->order_num} của bạn."
- );
- $paymentUrl = url('/client/invoices/' . $invoice->invoice_number . '/payment');
- try {
- AutoEmailGate::markManual();
- $invoicePdf = $this->invoicePdfService->generate($invoice);
- $invoicePdfFileName = $this->invoicePdfService->generateFileName($invoice);
-
- Mail::to($customer->email)->send(
- new InvoiceNotificationMail(
- $invoice,
- $attachments,
- $customer->fullname,
- $message,
- $companyName,
- $type,
- $invoicePdf,
- $invoicePdfFileName,
- $paymentUrl
- )
- );
- } catch (Throwable $e) {
- Log::error('InvoiceController@sendMail failed', [
- 'invoice_id' => $id,
- 'invoice_number' => $invoice->invoice_number ?? null,
- 'customer_email' => $customer->email,
- 'type' => $type,
- 'error' => $e->getMessage(),
- ]);
-
- return response()->json([
- 'error' => true,
- 'message' => 'Không thể gửi email: ' . $e->getMessage(),
- ], 500);
- }
-
- return response()->json([
- 'error' => false,
- 'message' => "Email đã được gửi tới {$customer->email}",
- ]);
+ return $invoice;
}
}
diff --git a/app/Http/Controllers/Api/Admin/Report/ExportController.php b/app/Http/Controllers/Api/Admin/Report/ExportController.php
index d457ae5..2a2c4fa 100644
--- a/app/Http/Controllers/Api/Admin/Report/ExportController.php
+++ b/app/Http/Controllers/Api/Admin/Report/ExportController.php
@@ -206,6 +206,7 @@ class ExportController extends Controller
'status' => match ((int) $model->is_created) {
1 => 'Đã khởi tạo',
2 => 'Không hoạt động',
+ 3 => 'Đã huỷ',
default => 'Chưa khởi tạo',
},
'created_at' => optional($model->created_at)->format('Y-m-d H:i'),
diff --git a/app/Http/Controllers/Api/Client/Service/DomainController.php b/app/Http/Controllers/Api/Client/Service/DomainController.php
index db4421e..0eeb354 100644
--- a/app/Http/Controllers/Api/Client/Service/DomainController.php
+++ b/app/Http/Controllers/Api/Client/Service/DomainController.php
@@ -3,7 +3,6 @@
namespace App\Http\Controllers\Api\Client\Service;
use App\Http\Controllers\Controller;
-use App\Http\Resources\Api\Service\DomainCollection;
use App\Http\Resources\Api\Service\DomainDetailResource;
use App\Http\Resources\Api\Service\DomainNSResource;
use App\Models\DnsDomain;
@@ -13,35 +12,17 @@ use App\Services\ServerService;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\DB;
+use Illuminate\Validation\Rule;
class DomainController extends Controller
{
+ public const CLIENT_EDITABLE_TYPES = ['A', 'AAAA', 'CNAME', 'TXT', 'MX', 'SRV'];
+
public function __construct(
protected ActivityLogger $activityLogger,
protected ServerService $serverService,
) {}
- public function index(Request $request): JsonResponse
- {
- $customer = $request->user();
- $perPage = min((int) $request->get('per_page', 15), 100);
-
- $domains = DnsDomain::query()
- ->with('server')
- ->withCount('record')
- ->whereHas('customer', fn ($query) => $query->where('customers.id', $customer->id))
- ->orderByDesc('id')
- ->paginate($perPage);
-
- return (new DomainCollection($domains))
- ->additional([
- 'error' => false,
- 'message' => 'Lấy dữ liệu thành công',
- 'page_type' => 'service_domain',
- ])
- ->response();
- }
-
public function show(Request $request, int $domainId): JsonResponse
{
$domain = $this->resolveCustomerDomain($request, $domainId);
@@ -52,8 +33,6 @@ class DomainController extends Controller
$perPage = min((int) $request->get('per_page', 15), 100);
- $domain->load('server')->loadCount('record');
-
$records = DnsManagerNS::query()
->where('dns_manager_domain_id', $domain->id)
->orderBy('type')
@@ -155,6 +134,10 @@ class DomainController extends Controller
return response()->json(['error' => true, 'message' => 'Không tìm thấy bản ghi DNS'], 404);
}
+ if ($locked = $this->lockedRecordResponse($record)) {
+ return $locked;
+ }
+
$payload = $this->validateDomainRecordPayload($request);
if (! empty($record->plesk_id)) {
@@ -223,6 +206,10 @@ class DomainController extends Controller
return response()->json(['error' => true, 'message' => 'Không tìm thấy bản ghi DNS'], 404);
}
+ if ($locked = $this->lockedRecordResponse($record)) {
+ return $locked;
+ }
+
if (! empty($record->plesk_id)) {
$this->serverService->findServerByID($domain->dns_manager_server_id);
$serverPayload = $this->serverService->deleteRecord((int) $record->plesk_id);
@@ -258,11 +245,20 @@ class DomainController extends Controller
]);
}
+ private function lockedRecordResponse(DnsManagerNS $record): ?JsonResponse
+ {
+ return in_array(strtoupper((string) $record->type), self::CLIENT_EDITABLE_TYPES, true) ? null : response()->json([
+ 'error' => true,
+ 'message' => "Bản ghi {$record->type} không thể tự thay đổi. Vui lòng liên hệ bộ phận kỹ thuật.",
+ ], 403);
+ }
+
private function resolveCustomerDomain(Request $request, int $domainId): ?DnsDomain
{
$customer = $request->user();
return DnsDomain::query()
+ ->notCancelled()
->with('server')
->where('id', $domainId)
->whereHas('customer', fn ($query) => $query->where('customers.id', $customer->id))
@@ -272,7 +268,7 @@ class DomainController extends Controller
private function validateDomainRecordPayload(Request $request): array
{
return $request->validate([
- 'type' => ['required', 'string', 'in:A,AAAA,CNAME,TXT,MX,NS,SRV'],
+ 'type' => ['required', 'string', Rule::in(self::CLIENT_EDITABLE_TYPES)],
'name' => ['required', 'string', 'max:255'],
'value' => ['required', 'string'],
'ttl' => ['nullable', 'integer', 'min:60', 'max:86400'],
diff --git a/app/Http/Controllers/Api/Client/Service/ServiceController.php b/app/Http/Controllers/Api/Client/Service/ServiceController.php
index aea7e8d..1925016 100644
--- a/app/Http/Controllers/Api/Client/Service/ServiceController.php
+++ b/app/Http/Controllers/Api/Client/Service/ServiceController.php
@@ -7,6 +7,7 @@ use App\Http\Resources\Api\Service\VpsServiceCollection;
use App\Http\Resources\Api\Service\Order\InvoiceSummaryResource;
use App\Http\Resources\Api\Client\Service\ServiceClientDetailResource;
use App\Http\Services\vCenter;
+use App\Models\DnsDomain;
use App\Models\Order;
use App\Models\Service;
use App\Repositories\Api\ServiceClient\ServiceClientRepository;
@@ -67,6 +68,56 @@ class ServiceController extends Controller
->response();
}
+ public function indexDomain(Request $request): JsonResponse
+ {
+ $customerId = $request->user()->id;
+
+ $zones = DnsDomain::query()
+ ->whereHas('customer', fn ($query) => $query->where('customers.id', $customerId))
+ ->get(['id', 'domain_name', 'is_created'])
+ ->keyBy(fn (DnsDomain $zone) => mb_strtolower(trim($zone->domain_name)));
+
+ $zoneFields = fn (?DnsDomain $zone) => [
+ 'dns_domain_id' => $zone && !$this->isCancelledZone($zone) ? $zone->id : null,
+ 'cancelled' => $zone !== null && $this->isCancelledZone($zone),
+ ];
+
+ $items = Service::query()
+ ->where('customer_id', $customerId)
+ ->where('type', 'domain')
+ ->orderByDesc('id')
+ ->get(['uuid', 'server_name', 'status', 'started_at', 'expires_at'])
+ ->map(fn (Service $service) => [
+ 'domain_name' => $service->server_name,
+ 'service_uuid' => $service->uuid,
+ 'status' => $service->status,
+ 'started_at' => $service->started_at?->toISOString(),
+ 'expires_at' => $service->expires_at?->toISOString(),
+ ...$zoneFields($zones->pull(mb_strtolower(trim((string) $service->server_name)))),
+ ])
+ ->concat($zones->values()->map(fn (DnsDomain $zone) => [
+ 'domain_name' => $zone->domain_name,
+ 'service_uuid' => null,
+ 'status' => null,
+ 'started_at' => null,
+ 'expires_at' => null,
+ ...$zoneFields($zone),
+ ]))
+ ->values();
+
+ return response()->json([
+ 'error' => false,
+ 'message' => 'Lấy dữ liệu thành công',
+ 'page_type' => 'service_domain',
+ 'data' => ['items' => $items],
+ ]);
+ }
+
+ private function isCancelledZone(DnsDomain $zone): bool
+ {
+ return (int) $zone->is_created === DnsDomain::STATUS_CANCELLED;
+ }
+
public function overview(Request $request, OrderQueryService $orderQueryService): JsonResponse
{
$customerId = (int) $request->user()->id;
diff --git a/app/Http/Controllers/Api/Service/OrderControllerRefactor.php b/app/Http/Controllers/Api/Service/OrderControllerRefactor.php
index 1435b2f..24ea652 100644
--- a/app/Http/Controllers/Api/Service/OrderControllerRefactor.php
+++ b/app/Http/Controllers/Api/Service/OrderControllerRefactor.php
@@ -215,7 +215,7 @@ class OrderControllerRefactor extends Controller
return response()->json([
'success' => true,
'data' => new InvoiceDetailResource($invoice),
- 'payment_url' => url('/client/invoices/' . $invoice->invoice_number . '/payment'),
+ 'payment_url' => $invoice->clientPath(),
]);
}
diff --git a/app/Http/Controllers/Api/Transaction/ManualDepositController.php b/app/Http/Controllers/Api/Transaction/ManualDepositController.php
index 6ced1db..706e9d2 100644
--- a/app/Http/Controllers/Api/Transaction/ManualDepositController.php
+++ b/app/Http/Controllers/Api/Transaction/ManualDepositController.php
@@ -94,7 +94,7 @@ class ManualDepositController extends Controller
}
$uuid = Str::uuid()->toString();
- $publicCode = 'MAN-' . now()->format('Ymd') . '-' . strtoupper(Str::random(6));
+ $publicCode = Transaction::newPublicCode('MAN');
DB::beginTransaction();
try {
diff --git a/app/Http/Resources/Api/Admin/OrderResource.php b/app/Http/Resources/Api/Admin/OrderResource.php
index 01d1166..b5a1425 100644
--- a/app/Http/Resources/Api/Admin/OrderResource.php
+++ b/app/Http/Resources/Api/Admin/OrderResource.php
@@ -70,6 +70,7 @@ class OrderResource extends JsonResource
'attachments' => $invoice->relationLoaded('attachments')
? $invoice->attachments->map(fn($attachment) => [
'id' => $attachment->id,
+ 'type' => $attachment->type,
'filename' => $attachment->filename,
'url' => $attachment->url,
'mime_type' => $attachment->mime_type,
diff --git a/app/Http/Resources/Api/Client/Service/ServiceClientDetailResource.php b/app/Http/Resources/Api/Client/Service/ServiceClientDetailResource.php
index 67e4feb..abbba22 100644
--- a/app/Http/Resources/Api/Client/Service/ServiceClientDetailResource.php
+++ b/app/Http/Resources/Api/Client/Service/ServiceClientDetailResource.php
@@ -2,6 +2,7 @@
namespace App\Http\Resources\Api\Client\Service;
+use App\Models\DnsDomain;
use App\Models\ModuleHostingPleskClient;
use Illuminate\Http\Request;
use Illuminate\Http\Resources\Json\JsonResource;
@@ -27,6 +28,18 @@ class ServiceClientDetailResource extends JsonResource
];
}
+ private function resolveDnsZone(): ?DnsDomain
+ {
+ if ($this->type !== 'domain' || !$this->server_name) {
+ return null;
+ }
+
+ return DnsDomain::query()
+ ->whereRaw('LOWER(domain_name) = ?', [mb_strtolower(trim($this->server_name))])
+ ->whereHas('customer', fn ($query) => $query->where('customers.id', $this->customer_id))
+ ->first(['id', 'is_created']);
+ }
+
/**
* Transform the resource into an array.
*
@@ -72,6 +85,8 @@ class ServiceClientDetailResource extends JsonResource
$product = $orderItem?->product;
$hosting = $this->resource->hostingAccount;
$pleskCredentials = $hosting ? $this->resolvePleskCredentials($hosting) : null;
+ $dnsZone = $this->resolveDnsZone();
+ $dnsCancelled = $dnsZone !== null && (int) $dnsZone->is_created === DnsDomain::STATUS_CANCELLED;
return [
'id' => $this->id,
@@ -115,6 +130,8 @@ class ServiceClientDetailResource extends JsonResource
'password' => $pleskCredentials['password'] ?? null,
'status' => $hosting->status,
] : null,
+ 'dns_domain_id' => $dnsZone && !$dnsCancelled ? $dnsZone->id : null,
+ 'dns_cancelled' => $dnsCancelled,
];
}
}
diff --git a/app/Http/Resources/Api/Service/DomainCollection.php b/app/Http/Resources/Api/Service/DomainCollection.php
deleted file mode 100644
index e922d3a..0000000
--- a/app/Http/Resources/Api/Service/DomainCollection.php
+++ /dev/null
@@ -1,34 +0,0 @@
-
- */
- public function toArray(Request $request): array
- {
- $data = [
- 'items' => $this->collection,
- ];
-
- if (method_exists($this->resource, 'total')) {
- $data['pagination'] = [
- 'total' => $this->resource->total(),
- 'per_page' => $this->resource->perPage(),
- 'current_page' => $this->resource->currentPage(),
- 'last_page' => $this->resource->lastPage(),
- ];
- }
-
- return $data;
- }
-}
diff --git a/app/Http/Resources/Api/Service/DomainDetailResource.php b/app/Http/Resources/Api/Service/DomainDetailResource.php
index 03b13cd..a8bacb6 100644
--- a/app/Http/Resources/Api/Service/DomainDetailResource.php
+++ b/app/Http/Resources/Api/Service/DomainDetailResource.php
@@ -20,8 +20,7 @@ class DomainDetailResource extends JsonResource
public function toArray(Request $request): array
{
return [
- 'domain' => new DomainResource($this->resource),
- 'ns_count' => (int) ($this->record_count ?? 0),
+ 'domain_name' => $this->domain_name,
'records' => $this->records ? new DomainNSCollection($this->records) : [
'items' => [],
'pagination' => [
diff --git a/app/Http/Resources/Api/Service/DomainNSResource.php b/app/Http/Resources/Api/Service/DomainNSResource.php
index 70a8a90..c20c8cf 100644
--- a/app/Http/Resources/Api/Service/DomainNSResource.php
+++ b/app/Http/Resources/Api/Service/DomainNSResource.php
@@ -16,10 +16,8 @@ class DomainNSResource extends JsonResource
{
return [
'id' => $this->id,
- 'dns_manager_domain_id' => $this->dns_manager_domain_id,
'name' => $this->name,
'type' => $this->type,
- 'type_valid' => $this->type_valid,
'priority' => $this->priority,
'weight' => $this->weight,
'port' => $this->port,
@@ -27,9 +25,6 @@ class DomainNSResource extends JsonResource
'protocol' => $this->protocol,
'value' => $this->value,
'ttl' => isset($this->ttl) ? (int) $this->ttl : null,
- 'plesk_id' => $this->plesk_id,
- 'created_at' => optional($this->created_at)->toISOString(),
- 'updated_at' => optional($this->updated_at)->toISOString(),
];
}
}
diff --git a/app/Http/Resources/Api/Service/DomainResource.php b/app/Http/Resources/Api/Service/DomainResource.php
deleted file mode 100644
index 691a524..0000000
--- a/app/Http/Resources/Api/Service/DomainResource.php
+++ /dev/null
@@ -1,30 +0,0 @@
-
- */
- public function toArray(Request $request): array
- {
- return [
- 'id' => $this->id,
- 'domain_name' => $this->domain_name,
- 'dns_manager_server_id' => $this->dns_manager_server_id,
- 'server_name' => $this->server?->name,
- 'server_ip' => $this->server?->ip_address,
- 'is_created' => isset($this->is_created) ? (int) $this->is_created : null,
- 'plesk_id' => $this->plesk_id,
- 'record_count' => $this->whenCounted('record'),
- 'created_at' => optional($this->created_at)->toISOString(),
- 'updated_at' => optional($this->updated_at)->toISOString(),
- ];
- }
-}
diff --git a/app/Http/Resources/Api/Service/Order/InvoiceDetailResource.php b/app/Http/Resources/Api/Service/Order/InvoiceDetailResource.php
index f9b9763..eda9889 100644
--- a/app/Http/Resources/Api/Service/Order/InvoiceDetailResource.php
+++ b/app/Http/Resources/Api/Service/Order/InvoiceDetailResource.php
@@ -3,6 +3,7 @@
namespace App\Http\Resources\Api\Service\Order;
use App\Enums\Invoice\InvoiceStatus;
+use App\Models\InvoiceAttachment;
use Illuminate\Http\Request;
use Illuminate\Http\Resources\Json\JsonResource;
@@ -46,7 +47,10 @@ class InvoiceDetailResource extends JsonResource
'months' => $item->months,
'os_id' => $item->os_id,
])->values()->all()),
- 'attachments' => $this->whenLoaded('attachments', fn () => $this->attachments->map(fn ($a) => [
+ 'attachments' => $this->whenLoaded('attachments', fn () => $this->attachments
+ ->where('type', '!=', InvoiceAttachment::TYPE_DEPLOYMENT)
+ ->values()
+ ->map(fn ($a) => [
'filename' => $a->filename,
'url' => $a->url,
'mime_type' => $a->mime_type,
diff --git a/app/Jobs/DnsManager/ProvisionDnsDomainJob.php b/app/Jobs/DnsManager/ProvisionDnsDomainJob.php
new file mode 100644
index 0000000..99da11c
--- /dev/null
+++ b/app/Jobs/DnsManager/ProvisionDnsDomainJob.php
@@ -0,0 +1,57 @@
+domainId);
+ if (!$domain || $domain->plesk_id) {
+ return;
+ }
+
+ $server = DnsServer::find($domain->dns_manager_server_id);
+ if (!$server) {
+ throw new RuntimeException("Không tìm thấy DNS server #{$domain->dns_manager_server_id} cho domain {$domain->domain_name}");
+ }
+
+ $pleskDomain = $plesk->getDomainInfo($server, $domain->domain_name)
+ ?: $plesk->createDomain($server, ['domain_name' => $domain->domain_name]);
+
+ if (empty($pleskDomain['id'])) {
+ throw new RuntimeException("Không tạo được domain {$domain->domain_name} trên Plesk: " . ($plesk->getLastError() ?? 'không rõ lỗi'));
+ }
+
+ $domain->forceFill(['plesk_id' => $pleskDomain['id'], 'is_created' => 1])->save();
+
+ Log::info('DNS domain provisioned on Plesk', [
+ 'domain' => $domain->domain_name,
+ 'server_id' => $server->id,
+ 'plesk_id' => $pleskDomain['id'],
+ ]);
+ }
+
+ public function failed(\Throwable $e): void
+ {
+ Log::error('ProvisionDnsDomainJob failed', ['domain_id' => $this->domainId, 'error' => $e->getMessage()]);
+ }
+}
diff --git a/app/Jobs/DnsManager/SuspendDnsDomainJob.php b/app/Jobs/DnsManager/SuspendDnsDomainJob.php
new file mode 100644
index 0000000..5ecc5e4
--- /dev/null
+++ b/app/Jobs/DnsManager/SuspendDnsDomainJob.php
@@ -0,0 +1,48 @@
+domainId);
+ if (!$domain || (int) $domain->is_created !== DnsDomain::STATUS_CANCELLED) {
+ return;
+ }
+
+ $server = DnsServer::find($domain->dns_manager_server_id);
+ if (!$server) {
+ throw new RuntimeException("Không tìm thấy DNS server #{$domain->dns_manager_server_id} cho domain {$domain->domain_name}");
+ }
+
+ if (!$plesk->suspend($server, $domain->domain_name)) {
+ throw new RuntimeException("Không suspend được domain {$domain->domain_name} trên Plesk: " . ($plesk->getLastError() ?: 'không rõ lỗi'));
+ }
+
+ Log::info('DNS domain suspended on Plesk', ['domain' => $domain->domain_name, 'server_id' => $server->id]);
+ }
+
+ public function failed(\Throwable $e): void
+ {
+ Log::error('SuspendDnsDomainJob failed', ['domain_id' => $this->domainId, 'error' => $e->getMessage()]);
+ }
+}
diff --git a/app/Jobs/DnsManager/SyncDnsFromPlesk.php b/app/Jobs/DnsManager/SyncDnsFromPlesk.php
index cc5fe3f..776f3d4 100644
--- a/app/Jobs/DnsManager/SyncDnsFromPlesk.php
+++ b/app/Jobs/DnsManager/SyncDnsFromPlesk.php
@@ -25,6 +25,7 @@ class SyncDnsFromPlesk implements ShouldQueue
ServerService $serverService,
): void {
$domains = $domainRepository->model
+ ->notCancelled()
->whereNotNull('dns_manager_server_id')
->get();
diff --git a/app/Jobs/Mail/SendInvoiceMailJob.php b/app/Jobs/Mail/SendInvoiceMailJob.php
new file mode 100644
index 0000000..5bee4af
--- /dev/null
+++ b/app/Jobs/Mail/SendInvoiceMailJob.php
@@ -0,0 +1,96 @@
+find($this->invoiceId);
+ if (!$invoice?->customer?->email) {
+ return;
+ }
+
+ $mail = null;
+ try {
+ $pdf = $pdfService->generate($invoice);
+ $pdfFileName = $pdfService->generateFileName($invoice);
+ $mail = $this->html !== null
+ ? $this->editedMail($templates, $pdf, $pdfFileName)
+ : InvoiceNotificationMail::forInvoice($invoice, $this->type, $this->message, $pdf, $pdfFileName);
+ $sent = Mail::to($invoice->customer->email)->send($mail);
+ $this->log($templates, $invoice, $mail, $sent === null ? 'failed' : 'sent');
+ } catch (Throwable $e) {
+ Log::error('SendInvoiceMailJob failed', [
+ 'invoice_id' => $invoice->id,
+ 'customer_email' => $invoice->customer->email,
+ 'type' => $this->type,
+ 'error' => $e->getMessage(),
+ ]);
+ if ($mail) {
+ $this->log($templates, $invoice, $mail, 'failed');
+ }
+
+ throw $e;
+ }
+ }
+
+ private function editedMail(EmailTemplateService $templates, string $pdf, string $pdfFileName): GenericTemplateMail
+ {
+ return (new GenericTemplateMail(
+ $this->subject,
+ $this->html,
+ $templates->loadTemplate(InvoiceNotificationMail::SCENARIO)->mailConfig(),
+ InvoiceNotificationMail::SCENARIO,
+ ))->attachData($pdf, $pdfFileName, ['mime' => 'application/pdf']);
+ }
+
+ private function log(EmailTemplateService $templates, Invoice $invoice, Mailable $mail, string $status): void
+ {
+ $envelope = $mail->envelope();
+ $addresses = fn (array $list) => implode(', ', array_map(fn ($a) => is_string($a) ? $a : $a->address, $list));
+
+ MailLog::create([
+ 'template_id' => $templates->loadTemplate(InvoiceNotificationMail::SCENARIO)->id,
+ 'subject' => $envelope->subject,
+ 'from_address' => $envelope->from?->address ?? config('mail.from.address'),
+ 'to' => $invoice->customer->email,
+ 'cc' => $addresses($envelope->cc),
+ 'bcc' => $addresses($envelope->bcc),
+ 'customer_id' => $invoice->customer_id,
+ 'order_id' => $invoice->order_id,
+ 'sent_by' => $this->sentBy,
+ 'message' => $mail->render(),
+ 'status' => $status,
+ ]);
+ }
+}
diff --git a/app/Mail/InvoiceNotificationMail.php b/app/Mail/InvoiceNotificationMail.php
index 41963d4..a147554 100644
--- a/app/Mail/InvoiceNotificationMail.php
+++ b/app/Mail/InvoiceNotificationMail.php
@@ -5,9 +5,9 @@ namespace App\Mail;
use App\Enums\Invoice\InvoiceStatus;
use App\Models\Invoice;
use App\Services\InvoicePaymentService;
+use App\Support\MailGlobals;
use App\Support\EmailTemplateShell;
use Carbon\Carbon;
-use Illuminate\Database\Eloquent\Collection;
use Illuminate\Mail\Mailables\Attachment;
/**
@@ -19,9 +19,10 @@ use Illuminate\Mail\Mailables\Attachment;
*/
class InvoiceNotificationMail extends TemplateMailable
{
+ public const SCENARIO = 'invoice_notification';
+
public function __construct(
public readonly Invoice $invoice,
- public readonly Collection $invoiceAttachments,
public readonly string $customerName,
public readonly string $message,
public readonly string $companyName,
@@ -31,6 +32,29 @@ class InvoiceNotificationMail extends TemplateMailable
public readonly ?string $paymentUrl = null,
) {}
+ public static function forInvoice(
+ Invoice $invoice,
+ string $type = 'invoice',
+ ?string $message = null,
+ ?string $invoicePdf = null,
+ ?string $invoicePdfFileName = null,
+ ): self {
+ $message ??= $type === 'renewal'
+ ? "Dịch vụ của quý khách sắp đến hạn gia hạn. Vui lòng kiểm tra hóa đơn #{$invoice->order_num} được đính kèm và hoàn tất thanh toán để dịch vụ không bị gián đoạn."
+ : "Vui lòng xem thông tin hóa đơn #{$invoice->order_num} của bạn.";
+
+ return new self(
+ $invoice,
+ $invoice->customer->fullname,
+ $message,
+ setting('general.company_name') ?: config('app.name'),
+ $type,
+ $invoicePdf,
+ $invoicePdfFileName,
+ MailGlobals::frontendUrl() . $invoice->clientPath(),
+ );
+ }
+
public function attachments(): array
{
if ($this->invoicePdf !== null && $this->invoicePdfFileName !== null) {
@@ -45,7 +69,7 @@ class InvoiceNotificationMail extends TemplateMailable
protected function scenario(): string
{
- return 'invoice_notification';
+ return self::SCENARIO;
}
protected function variables(): array
@@ -67,7 +91,7 @@ class InvoiceNotificationMail extends TemplateMailable
? 'File PDF hóa đơn gia hạn đã được đính kèm trong email này. Vui lòng hoàn tất thanh toán trước hạn để dịch vụ không bị gián đoạn.'
: 'File PDF hóa đơn đã được đính kèm trong email này để Quý khách tiện lưu trữ và đối chiếu.',
'pay_button' => $this->payButton(),
- 'attachments_block' => $this->attachmentsBlock(),
+ 'attachments_block' => '',
'note_block' => $this->noteBlock(),
];
}
@@ -132,24 +156,6 @@ class InvoiceNotificationMail extends TemplateMailable
. '
Hoặc sao chép liên kết thanh toán: ' . $url . '
';
}
- private function attachmentsBlock(): string
- {
- if ($this->invoiceAttachments->isEmpty()) {
- return '';
- }
-
- $items = $this->invoiceAttachments
- ->map(fn($file) => ''
- . htmlspecialchars((string) $file->filename, ENT_QUOTES, 'UTF-8')
- . '')
- ->implode('');
-
- return '';
- }
-
private function noteBlock(): string
{
if (empty($this->invoice->note)) {
diff --git a/app/Models/DnsDomain.php b/app/Models/DnsDomain.php
index 151d23d..e0b8144 100644
--- a/app/Models/DnsDomain.php
+++ b/app/Models/DnsDomain.php
@@ -10,6 +10,11 @@ class DnsDomain extends Model
{
use HasFactory, LogsActivity;
+ public const STATUS_PENDING = 0;
+ public const STATUS_CREATED = 1;
+ public const STATUS_INACTIVE = 2;
+ public const STATUS_CANCELLED = 3;
+
protected $table = 'dns_manager_domain';
protected $fillable = [
@@ -20,6 +25,11 @@ class DnsDomain extends Model
'registrar_id',
];
+ public function scopeNotCancelled($query)
+ {
+ return $query->where('is_created', '!=', self::STATUS_CANCELLED);
+ }
+
public function customer()
{
return $this->belongsToMany(Customer::class, 'dns_domain_user', 'domain_id', 'user_id')->withTimestamps();
diff --git a/app/Models/Invoice.php b/app/Models/Invoice.php
index cdc54af..8ceadc8 100644
--- a/app/Models/Invoice.php
+++ b/app/Models/Invoice.php
@@ -36,6 +36,11 @@ class Invoice extends Model
}
/** The order that originated this invoice. */
+ public function clientPath(): string
+ {
+ return '/hoa-don/' . ($this->order?->order_num ?? $this->invoice_number);
+ }
+
public function order()
{
return $this->belongsTo(Order::class, 'order_id');
diff --git a/app/Models/InvoiceAttachment.php b/app/Models/InvoiceAttachment.php
index 4bbf35f..10440fe 100644
--- a/app/Models/InvoiceAttachment.php
+++ b/app/Models/InvoiceAttachment.php
@@ -7,6 +7,9 @@ use Illuminate\Support\Facades\Storage;
class InvoiceAttachment extends Model
{
+ public const TYPE_PAYMENT_PROOF = 'payment_proof';
+ public const TYPE_DEPLOYMENT = 'deployment';
+
protected $table = 'invoice_attachments';
protected $guarded = [];
diff --git a/app/Models/Mail.php b/app/Models/Mail.php
index 77a0db1..791fd40 100644
--- a/app/Models/Mail.php
+++ b/app/Models/Mail.php
@@ -20,6 +20,7 @@ class Mail extends Model
'cc',
'bcc',
'customer_id',
+ 'order_id',
'sent_by',
'message',
'status'
diff --git a/app/Models/Transaction.php b/app/Models/Transaction.php
index c418c2f..812a543 100644
--- a/app/Models/Transaction.php
+++ b/app/Models/Transaction.php
@@ -2,11 +2,14 @@
namespace App\Models;
+use App\Services\Cache\CacheServiceInterface;
use App\Traits\LogsActivity;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\SoftDeletes;
+use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\URL;
+use Illuminate\Support\Str;
use App\Traits\HasUuid;
class Transaction extends Model
@@ -30,6 +33,24 @@ class Transaction extends Model
protected $appends = ['direction', 'proof_image_urls'];
+ protected static function booted(): void
+ {
+ static::creating(function (Transaction $transaction) {
+ $transaction->public_code ??= self::newPublicCode('TXN');
+ });
+
+ $flushListCache = fn () => DB::afterCommit(
+ fn () => app(CacheServiceInterface::class)->clearByModule('transaction')
+ );
+ static::saved($flushListCache);
+ static::deleted($flushListCache);
+ }
+
+ public static function newPublicCode(string $prefix): string
+ {
+ return $prefix . '-' . now()->format('Ymd') . '-' . strtoupper(Str::random(6));
+ }
+
public function customer()
{
return $this->belongsTo(Customer::class, 'customer_id');
@@ -53,7 +74,7 @@ class Transaction extends Model
'admin_add_total', 'admin_add_credit', 'manual', 'refund' => 'credit',
'admin_deduct_credit' => 'debit',
'balance' => 'debit',
- 'transfer' => 'neutral',
+ 'transfer', 'admin_record' => 'neutral',
'invoice' => str_starts_with((string) $this->note, 'Admin xác nhận')
? 'neutral'
: 'debit',
diff --git a/app/Services/Api/Domain/DomainOrderService.php b/app/Services/Api/Domain/DomainOrderService.php
index d51436a..8b541ee 100644
--- a/app/Services/Api/Domain/DomainOrderService.php
+++ b/app/Services/Api/Domain/DomainOrderService.php
@@ -122,7 +122,7 @@ class DomainOrderService
Transaction::create([
'uuid' => (string) Str::uuid(),
- 'public_code' => 'DOM-' . now()->format('Ymd') . '-' . strtoupper(Str::random(6)),
+ 'public_code' => Transaction::newPublicCode('DOM'),
'trans' => $invoice->invoice_number,
'amount' => $price,
'customer_id' => $lockedCustomer->id,
@@ -341,7 +341,7 @@ class DomainOrderService
Transaction::create([
'uuid' => (string) Str::uuid(),
- 'public_code' => 'DOM-REF-' . now()->format('Ymd') . '-' . strtoupper(Str::random(6)),
+ 'public_code' => Transaction::newPublicCode('DOM-REF'),
'trans' => 'REF-' . $invoice->invoice_number,
'amount' => $refundAmount,
'customer_id' => $lockedCustomer->id,
diff --git a/app/Services/Api/Order/OrderQueryService.php b/app/Services/Api/Order/OrderQueryService.php
index 4034c1b..6059a30 100644
--- a/app/Services/Api/Order/OrderQueryService.php
+++ b/app/Services/Api/Order/OrderQueryService.php
@@ -223,7 +223,7 @@ class OrderQueryService
'invoice_number' => $invoice?->invoice_number,
'total' => (float) ($invoice?->total ?? 0),
'status' => $invoice?->status,
- 'payment_url' => url('/client/invoices/' . $invoice?->invoice_number . '/payment'),
+ 'payment_url' => $invoice?->clientPath(),
],
];
}
@@ -278,7 +278,7 @@ class OrderQueryService
'invoice_number' => $invoice->invoice_number,
'total' => (float) $invoice->total,
'status' => $invoice->status,
- 'payment_url' => url('/client/invoices/' . $invoice->invoice_number . '/payment'),
+ 'payment_url' => $invoice->clientPath(),
],
];
}
diff --git a/app/Services/Cache/CacheService.php b/app/Services/Cache/CacheService.php
index 46a96a5..fe33e3a 100644
--- a/app/Services/Cache/CacheService.php
+++ b/app/Services/Cache/CacheService.php
@@ -3,10 +3,11 @@
namespace App\Services\Cache;
use Closure;
+use Illuminate\Cache\RedisStore;
use Illuminate\Contracts\Cache\Repository as CacheRepository;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\Log;
-use Illuminate\Support\Facades\Redis;
+use Illuminate\Support\Str;
use stdClass;
class CacheService implements CacheServiceInterface
@@ -66,11 +67,6 @@ class CacheService implements CacheServiceInterface
public function clearByModule(string $module): void
{
- if ($this->supportsTagging()) {
- Cache::tags([$module])->flush();
- return;
- }
-
$this->scanAndDelete($module);
}
@@ -87,32 +83,24 @@ class CacheService implements CacheServiceInterface
return Cache::store();
}
- /**
- * Delete all keys for a module using SCAN (safe for production).
- *
- * KEYS is O(N) and blocks Redis; SCAN iterates in batches without blocking.
- * The pattern matches: {cache_prefix}:{module}:*
- */
private function scanAndDelete(string $module): void
{
- $redis = Redis::connection('cache');
- $prefix = config('cache.prefix');
- $pattern = "{$prefix}:{$module}:*";
- $cursor = '0';
+ $store = Cache::store()->getStore();
+ if (! $store instanceof RedisStore) {
+ return;
+ }
+
+ $redis = $store->connection();
+ $connectionPrefix = (string) config('database.redis.options.prefix');
+ $pattern = $connectionPrefix . $store->getPrefix() . $module . ':*';
+ $cursor = '0';
do {
- $result = $redis->scan($cursor, 'MATCH', $pattern, 'COUNT', 100);
-
- if ($result === false) {
- break;
- }
-
- [$cursor, $keys] = $result;
+ [$cursor, $keys] = $redis->scan($cursor, ['match' => $pattern, 'count' => 500]) ?: ['0', []];
foreach ((array) $keys as $key) {
- $redis->del($key);
+ $redis->del(Str::after($key, $connectionPrefix));
}
-
} while ((string) $cursor !== '0');
}
diff --git a/app/Services/Customer/CustomerBillingService.php b/app/Services/Customer/CustomerBillingService.php
index 638b22d..6e2c0d2 100644
--- a/app/Services/Customer/CustomerBillingService.php
+++ b/app/Services/Customer/CustomerBillingService.php
@@ -4,6 +4,7 @@ namespace App\Services\Customer;
use App\Models\Invoice;
use App\Models\Order;
+use App\Enums\TransactionStatus;
use App\Models\Transaction;
use App\Repositories\Customer\CustomerRepositoryInterface;
use App\Repositories\Invoice\InvoiceRepositoryInterface;
@@ -11,6 +12,7 @@ use App\Repositories\Order\OrderRepositoryInterface;
use App\Repositories\Transaction\TransactionRepositoryInterface;
use App\Services\Customer\Concerns\ResolvesCustomer;
use Illuminate\Support\Collection;
+use Illuminate\Support\Str;
class CustomerBillingService
{
@@ -91,6 +93,36 @@ class CustomerBillingService
return $transaction->refresh()->load('user');
}
+ public function createTransaction(string $uuid, array $data, ?int $adminId): Transaction
+ {
+ $customer = $this->resolveCustomer($uuid);
+
+ $transaction = $this->transactionRepository->create([
+ 'uuid' => (string) Str::uuid(),
+ 'public_code' => Transaction::newPublicCode('ADM'),
+ 'trans' => $data['trans'] ?? null,
+ 'amount' => $data['amount'],
+ 'received_amount' => $data['amount'],
+ 'customer_id' => $customer->id,
+ 'user_id' => $adminId,
+ 'status' => $data['status'] ?? TransactionStatus::COMPLETED->value,
+ 'type' => 'admin_record',
+ 'note' => $data['note'] ?? null,
+ ]);
+
+ return $transaction->load('user');
+ }
+
+ public function deleteTransaction(string $uuid, int $transactionId): void
+ {
+ $customer = $this->resolveCustomer($uuid);
+
+ $exists = Transaction::where('customer_id', $customer->id)->where('id', $transactionId)->exists();
+ abort_if(!$exists, 404, 'Không tìm thấy giao dịch');
+
+ $this->transactionRepository->delete($transactionId);
+ }
+
public function getOrders(string $uuid): Collection
{
$customer = $this->resolveCustomer($uuid);
diff --git a/app/Services/Customer/CustomerServiceProvisioningService.php b/app/Services/Customer/CustomerServiceProvisioningService.php
index 95a88ae..7fc971a 100644
--- a/app/Services/Customer/CustomerServiceProvisioningService.php
+++ b/app/Services/Customer/CustomerServiceProvisioningService.php
@@ -294,6 +294,7 @@ class CustomerServiceProvisioningService
Transaction::create([
'uuid' => (string) Str::uuid(),
+ 'public_code' => Transaction::newPublicCode('SVC'),
'customer_id' => $customer->id,
'user_id' => Auth::id(),
'trans' => $order->order_num,
diff --git a/app/Services/Customer/CustomerWalletService.php b/app/Services/Customer/CustomerWalletService.php
index 99ab329..4c6dc9d 100644
--- a/app/Services/Customer/CustomerWalletService.php
+++ b/app/Services/Customer/CustomerWalletService.php
@@ -83,6 +83,7 @@ class CustomerWalletService
{
return Transaction::create([
'uuid' => (string) Str::uuid(),
+ 'public_code' => Transaction::newPublicCode('ADM'),
'trans' => 'ADMIN' . now()->format('YmdHis'),
'amount' => $amount,
'customer_id' => $customer->id,
diff --git a/app/Services/Email/EmailTemplateService.php b/app/Services/Email/EmailTemplateService.php
index ec5cd73..e7e1fab 100644
--- a/app/Services/Email/EmailTemplateService.php
+++ b/app/Services/Email/EmailTemplateService.php
@@ -4,6 +4,7 @@ namespace App\Services\Email;
use App\Jobs\Mail\SendTemplateEmailJob;
use App\Models\Template;
+use App\Support\AutoEmailGate;
use Illuminate\Support\Facades\Cache;
/**
@@ -21,6 +22,10 @@ class EmailTemplateService
*/
public function send(string $scenario, string|array $to, array $data = []): void
{
+ if (!AutoEmailGate::allows($scenario)) {
+ return;
+ }
+
SendTemplateEmailJob::dispatch($scenario, is_array($to) ? $to : [$to], $data);
}
diff --git a/app/Services/InvoicePaymentService.php b/app/Services/InvoicePaymentService.php
index 0e591fd..38f0ad5 100644
--- a/app/Services/InvoicePaymentService.php
+++ b/app/Services/InvoicePaymentService.php
@@ -243,7 +243,7 @@ class InvoicePaymentService
Transaction::create([
'uuid' => (string) Str::uuid(),
- 'public_code' => 'INV-REF-' . now()->format('Ymd') . '-' . strtoupper(Str::random(6)),
+ 'public_code' => Transaction::newPublicCode('INV-REF'),
'trans' => $invoice->invoice_number . '-REFUND',
'type' => 'refund',
'amount' => -$refundAmount,
@@ -295,7 +295,7 @@ class InvoicePaymentService
],
[
'uuid' => (string) Str::uuid(),
- 'public_code' => 'INV-PAY-' . now()->format('Ymd') . '-' . strtoupper(Str::random(6)),
+ 'public_code' => Transaction::newPublicCode('INV-PAY'),
'amount' => $amount,
'received_amount' => $amount,
'user_id' => $adminId,
diff --git a/app/Services/OrderService.php b/app/Services/OrderService.php
index 8624908..5595b83 100644
--- a/app/Services/OrderService.php
+++ b/app/Services/OrderService.php
@@ -24,6 +24,7 @@ use App\Repositories\InvoiceItem\InvoiceItemRepositoryInterface;
use App\Repositories\Module\Hosting\HostingRepositoryInterface as ModuleHostingRepositoryInterface;
use App\Repositories\Module\HostingServer\HostingServerRepositoryInterface;
use App\Repositories\Order\OrderRepositoryInterface;
+use App\Support\AutoEmailGate;
use Carbon\Carbon;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\DB;
@@ -246,7 +247,7 @@ class OrderService
],
[
'uuid' => (string) Str::uuid(),
- 'public_code' => 'INV-PAY-' . now()->format('Ymd') . '-' . strtoupper(Str::random(6)),
+ 'public_code' => Transaction::newPublicCode('INV-PAY'),
'amount' => $orderTotal,
'user_id' => Auth::guard('admin')->id(),
'status' => TransactionStatus::COMPLETED->value,
@@ -258,15 +259,17 @@ class OrderService
return $order;
});
- OrderCreatedJob::dispatch([
- 'customer_email' => $customer->email,
- 'client_name' => $customer->fullname,
- 'order_num' => $orderData['order_num'],
- 'invoice_num' => $invoiceData['invoice_number'],
- 'order_total' => number_format($orderTotal, 0, ',', '.'),
- 'company_name' => setting('general.company_name') ?: config('app.name'),
- 'company_logo' => setting('general.company_logo') ?: '',
- ]);
+ if (AutoEmailGate::allows('order_created')) {
+ OrderCreatedJob::dispatch([
+ 'customer_email' => $customer->email,
+ 'client_name' => $customer->fullname,
+ 'order_num' => $orderData['order_num'],
+ 'invoice_num' => $invoiceData['invoice_number'],
+ 'order_total' => number_format($orderTotal, 0, ',', '.'),
+ 'company_name' => setting('general.company_name') ?: config('app.name'),
+ 'company_logo' => setting('general.company_logo') ?: '',
+ ]);
+ }
return $order;
}
diff --git a/app/Services/Plesk/PleskDomainService.php b/app/Services/Plesk/PleskDomainService.php
index 3553df0..6b1da42 100644
--- a/app/Services/Plesk/PleskDomainService.php
+++ b/app/Services/Plesk/PleskDomainService.php
@@ -3,6 +3,7 @@
namespace App\Services\Plesk;
use App\Models\DnsServer;
+use Illuminate\Http\Client\ConnectionException;
use Illuminate\Http\Client\RequestException;
use Illuminate\Support\Facades\Log;
use Illuminate\Support\Str;
@@ -83,6 +84,28 @@ class PleskDomainService
return false;
}
+ public function suspend(DnsServer $server, string $domain): bool
+ {
+ $this->lastError = null;
+
+ try {
+ $response = $this->plesk->client($server, 30)
+ ->post($this->plesk->url($server, 'cli', '/subscription/call'), ['params' => ['--webspace-off', $domain]]);
+ } catch (ConnectionException $e) {
+ $this->lastError = $e->getMessage();
+
+ return false;
+ }
+
+ if ($response->successful() && (int) $response->json('code', 1) === 0) {
+ return true;
+ }
+
+ $this->lastError = trim((string) ($response->json('stderr') ?: $response->json('message') ?: $response->body()));
+
+ return false;
+ }
+
public function getDomainInfo(DnsServer $server, string $domain = ''): array|false
{
if (empty($domain)) {
diff --git a/app/Support/AutoEmailGate.php b/app/Support/AutoEmailGate.php
index 6d9fbad..3c41105 100644
--- a/app/Support/AutoEmailGate.php
+++ b/app/Support/AutoEmailGate.php
@@ -25,6 +25,11 @@ final class AutoEmailGate
return in_array($scenario, self::disabledScenarios(), true);
}
+ public static function allows(?string $scenario): bool
+ {
+ return self::mailEnabled() && !self::blocks($scenario);
+ }
+
public static function mailEnabled(): bool
{
return (bool) (self::emailSettings()['mail_enabled'] ?? true);
diff --git a/config/app.php b/config/app.php
index 3c05608..f78296c 100644
--- a/config/app.php
+++ b/config/app.php
@@ -57,7 +57,7 @@ return [
'url' => env('APP_URL', 'http://localhost'),
- 'frontend_url' => 'https://portal.tgs.com.vn',
+ 'frontend_url' => env('FRONTEND_URL', 'https://portal.tgs.com.vn'),
'api_agency_url' => env('API_AGENCY_URL'),
diff --git a/config/database.php b/config/database.php
index d18b753..f53f85b 100644
--- a/config/database.php
+++ b/config/database.php
@@ -177,7 +177,7 @@ return [
'password' => env('REDIS_PASSWORD'),
'port' => env('REDIS_PORT', '6379'),
'database' => env('REDIS_DB', '0'),
- 'persistent' => true,
+ 'persistent' => env('REDIS_PERSISTENT', false),
'persistent_id' => 'default',
'read_write_timeout' => 0,
],
@@ -188,7 +188,7 @@ return [
'password' => env('REDIS_PASSWORD'),
'port' => env('REDIS_PORT', '6379'),
'database' => env('REDIS_CACHE_DB', '1'),
- 'persistent' => true,
+ 'persistent' => env('REDIS_PERSISTENT', false),
'persistent_id' => 'cache',
'read_write_timeout' => 0,
],
diff --git a/database/migrations/2026_10_09_140000_backfill_transaction_public_codes.php b/database/migrations/2026_10_09_140000_backfill_transaction_public_codes.php
new file mode 100644
index 0000000..7726553
--- /dev/null
+++ b/database/migrations/2026_10_09_140000_backfill_transaction_public_codes.php
@@ -0,0 +1,30 @@
+whereNull('public_code')
+ ->orderBy('id')
+ ->select(['id', 'type', 'created_at'])
+ ->chunkById(500, function ($transactions) {
+ foreach ($transactions as $transaction) {
+ $prefix = $transaction->type === 'transfer' || $transaction->type === 'balance' ? 'SVC' : 'TXN';
+ $date = $transaction->created_at ? date('Ymd', strtotime($transaction->created_at)) : now()->format('Ymd');
+
+ DB::table('transactions')
+ ->where('id', $transaction->id)
+ ->update(['public_code' => $prefix . '-' . $date . '-' . strtoupper(Str::random(6))]);
+ }
+ });
+ }
+
+ public function down(): void
+ {
+ }
+};
diff --git a/database/migrations/2026_10_09_150000_add_type_to_invoice_attachments_table.php b/database/migrations/2026_10_09_150000_add_type_to_invoice_attachments_table.php
new file mode 100644
index 0000000..4e6a412
--- /dev/null
+++ b/database/migrations/2026_10_09_150000_add_type_to_invoice_attachments_table.php
@@ -0,0 +1,22 @@
+string('type', 30)->default('payment_proof')->after('invoice_id')->index();
+ });
+ }
+
+ public function down(): void
+ {
+ Schema::table('invoice_attachments', function (Blueprint $table) {
+ $table->dropColumn('type');
+ });
+ }
+};
diff --git a/database/migrations/2026_10_09_160000_add_order_id_to_mail_table.php b/database/migrations/2026_10_09_160000_add_order_id_to_mail_table.php
new file mode 100644
index 0000000..2eaf71a
--- /dev/null
+++ b/database/migrations/2026_10_09_160000_add_order_id_to_mail_table.php
@@ -0,0 +1,23 @@
+unsignedBigInteger('order_id')->nullable()->index()->after('customer_id');
+ });
+ }
+
+ public function down(): void
+ {
+ Schema::table('mail', function (Blueprint $table) {
+ $table->dropIndex(['order_id']);
+ $table->dropColumn('order_id');
+ });
+ }
+};
diff --git a/routes/api/admin/billing.php b/routes/api/admin/billing.php
index 392010a..326bd5a 100644
--- a/routes/api/admin/billing.php
+++ b/routes/api/admin/billing.php
@@ -44,6 +44,7 @@ Route::prefix('invoices')
);
// Send mail notification
Route::post('{id}/send-mail', 'sendMail')->middleware('perm:invoice.edit');
+ Route::post('{id}/send-mail/preview', 'previewMail')->middleware('perm:invoice.edit');
});
// Reports / Data exports
diff --git a/routes/api/admin/customers.php b/routes/api/admin/customers.php
index 6067a50..043fefe 100644
--- a/routes/api/admin/customers.php
+++ b/routes/api/admin/customers.php
@@ -54,6 +54,10 @@ Route::prefix('customers')
Route::put('{uuid}/transactions/{transactionId}', 'updateTransaction')->middleware(
'perm:customer.edit',
);
+ Route::post('{uuid}/transactions', 'createTransaction')->middleware('perm:customer.edit');
+ Route::delete('{uuid}/transactions/{transactionId}', 'deleteTransaction')->middleware(
+ 'perm:customer.edit',
+ );
Route::get('{uuid}/domains', 'getDomains')->middleware('perm:customer.view');
Route::get('{uuid}/dns-domains', 'getDnsDomains')->middleware('perm:customer.view');
Route::get('{uuid}/dns-domains/{domainId}/records', 'getDnsDomainRecords')->middleware(
diff --git a/routes/api/admin/domains.php b/routes/api/admin/domains.php
index 0eda096..8f9f7b5 100644
--- a/routes/api/admin/domains.php
+++ b/routes/api/admin/domains.php
@@ -18,6 +18,7 @@ Route::prefix('domains')
Route::post('sync-dns', 'syncDns')->middleware('perm:domain.manager');
Route::get('{id}', 'show')->middleware('perm:domain.manager');
Route::put('{id}', 'update')->middleware('perm:domain.manager');
+ Route::post('{id}/provision', 'provision')->middleware('perm:domain.manager');
Route::delete('{id}', 'destroy')->middleware('perm:domain.manager');
// DNS Records
Route::get('{id}/dns', 'dnsIndex')->middleware('perm:domain.manager');
diff --git a/routes/client.php b/routes/client.php
index 4f2e5cb..3d0f15b 100644
--- a/routes/client.php
+++ b/routes/client.php
@@ -88,7 +88,6 @@ Route::prefix('v1')
->controller(ClientDomainController::class)
->as('client-service.domains.')
->group(function () {
- Route::get('/', 'index')->name('list');
Route::get('{domainId}', 'show')->name('detail');
Route::get('{domainId}/records', 'show')->name('records');
Route::post('{domainId}/records', 'storeRecord')->name('records.store');
@@ -108,6 +107,7 @@ Route::prefix('v1')
Route::get('vps', 'indexVps')->name('vps.list');
Route::get('hosting', 'indexHosting')->name('hosting.list');
Route::get('web-design', 'indexWebDesign')->name('web-design.list');
+ Route::get('domain', 'indexDomain')->name('domain.list');
Route::get('{uuid}', 'getDetail')->name('detail');
Route::get('{uuid}/console-ticket', 'getConsoleTicket')->name('console-ticket');
Route::get('{uuid}/available-os', 'getAvailableOs')->name('available-os');
diff --git a/storage/app/public/payment-proofs/AzDvDqZhMFkoeVnCJzMvE0t4XzpNlAATv6JNlBsS.png b/storage/app/public/payment-proofs/AzDvDqZhMFkoeVnCJzMvE0t4XzpNlAATv6JNlBsS.png
new file mode 100644
index 0000000..d43bb91
Binary files /dev/null and b/storage/app/public/payment-proofs/AzDvDqZhMFkoeVnCJzMvE0t4XzpNlAATv6JNlBsS.png differ
diff --git a/storage/app/public/payment-proofs/LYPGdnrxIQIdlqgHsN5cD09Dawe0dbBujrCrtZWE.png b/storage/app/public/payment-proofs/LYPGdnrxIQIdlqgHsN5cD09Dawe0dbBujrCrtZWE.png
new file mode 100644
index 0000000..d43bb91
Binary files /dev/null and b/storage/app/public/payment-proofs/LYPGdnrxIQIdlqgHsN5cD09Dawe0dbBujrCrtZWE.png differ
diff --git a/storage/app/public/payment-proofs/mCDaj0OevvzBzyPbeLFYoQ87aDg0DnYYyVwucaDu.png b/storage/app/public/payment-proofs/mCDaj0OevvzBzyPbeLFYoQ87aDg0DnYYyVwucaDu.png
new file mode 100644
index 0000000..d43bb91
Binary files /dev/null and b/storage/app/public/payment-proofs/mCDaj0OevvzBzyPbeLFYoQ87aDg0DnYYyVwucaDu.png differ
diff --git a/tests/Feature/Api/Admin/Customer/ManualTransactionTest.php b/tests/Feature/Api/Admin/Customer/ManualTransactionTest.php
new file mode 100644
index 0000000..a4b3c26
--- /dev/null
+++ b/tests/Feature/Api/Admin/Customer/ManualTransactionTest.php
@@ -0,0 +1,63 @@
+ 'Full Administrator', 'guard_name' => 'admin']);
+ $admin = User::factory()->create();
+ $admin->assignRole('Full Administrator');
+ $customer = Customer::factory()->create(['total' => 500000]);
+ $url = "/api/v1/administrator/customers/{$customer->uuid}/transactions";
+
+ $created = $this->actingAs($admin, 'admin')
+ ->postJson($url, ['trans' => 'CK VCB 123', 'amount' => 800000, 'note' => 'Thu tiền mặt'])
+ ->assertCreated()
+ ->json('data');
+
+ $this->assertMatchesRegularExpression('/^ADM-\d{8}-[A-Z0-9]{6}$/', $created['public_code']);
+ $this->assertSame('neutral', $created['direction']);
+ $this->assertSame(500000, (int) $customer->fresh()->total);
+ $this->actingAs($admin, 'admin')->getJson($url)->assertJsonCount(1, 'data');
+
+ $this->actingAs($admin, 'admin')->deleteJson("{$url}/{$created['id']}")->assertOk();
+ $this->assertSoftDeleted('transactions', ['id' => $created['id']]);
+ }
+
+ public function test_transaction_created_without_code_gets_one(): void
+ {
+ $customer = Customer::factory()->create();
+
+ $transaction = Transaction::create(['amount' => 1, 'customer_id' => $customer->id, 'type' => 'transfer', 'status' => 1]);
+
+ $this->assertMatchesRegularExpression('/^TXN-\d{8}-[A-Z0-9]{6}$/', $transaction->public_code);
+ }
+
+ public function test_cannot_delete_another_customers_transaction(): void
+ {
+ Role::firstOrCreate(['name' => 'Full Administrator', 'guard_name' => 'admin']);
+ $admin = User::factory()->create();
+ $admin->assignRole('Full Administrator');
+ $owner = Customer::factory()->create();
+ $other = Customer::factory()->create();
+ $transaction = Transaction::create(['amount' => 1, 'customer_id' => $owner->id, 'type' => 'transfer', 'status' => 1]);
+
+ $this->actingAs($admin, 'admin')
+ ->deleteJson("/api/v1/administrator/customers/{$other->uuid}/transactions/{$transaction->id}")
+ ->assertNotFound();
+ $this->assertNotSoftDeleted('transactions', ['id' => $transaction->id]);
+ }
+}
diff --git a/tests/Feature/Api/Admin/DnsManager/ProvisionDnsDomainTest.php b/tests/Feature/Api/Admin/DnsManager/ProvisionDnsDomainTest.php
new file mode 100644
index 0000000..623d18e
--- /dev/null
+++ b/tests/Feature/Api/Admin/DnsManager/ProvisionDnsDomainTest.php
@@ -0,0 +1,167 @@
+serverId = DB::table('dns_manager_servers')->insertGetId(['ip_address' => '10.0.0.1', 'password' => 'x']);
+ }
+
+ private function domain(): DnsDomain
+ {
+ return DnsDomain::create(['domain_name' => 'example.vn', 'dns_manager_server_id' => $this->serverId, 'is_created' => 0]);
+ }
+
+ private function admin(): User
+ {
+ Role::firstOrCreate(['name' => 'Full Administrator', 'guard_name' => 'admin']);
+ $admin = User::factory()->create();
+ $admin->assignRole('Full Administrator');
+
+ return $admin;
+ }
+
+ public function test_destroy_cancels_domain_and_queues_plesk_suspend(): void
+ {
+ Queue::fake();
+ $domain = $this->domain();
+ $domain->update(['plesk_id' => 55, 'is_created' => DnsDomain::STATUS_CREATED]);
+ $customerId = \App\Models\Customer::factory()->create()->id;
+ $domain->customer()->attach($customerId);
+
+ $this->actingAs($this->admin(), 'admin')
+ ->deleteJson("/api/v1/administrator/domains/{$domain->id}")
+ ->assertOk();
+
+ $this->assertDatabaseHas('dns_manager_domain', ['id' => $domain->id, 'is_created' => DnsDomain::STATUS_CANCELLED]);
+ $this->assertDatabaseHas('dns_domain_user', ['domain_id' => $domain->id, 'user_id' => $customerId]);
+ Queue::assertPushed(SuspendDnsDomainJob::class, fn ($job) => $job->domainId === $domain->id);
+ }
+
+ public function test_destroy_without_plesk_domain_only_cancels(): void
+ {
+ Queue::fake();
+ $domain = $this->domain();
+
+ $this->actingAs($this->admin(), 'admin')
+ ->deleteJson("/api/v1/administrator/domains/{$domain->id}")
+ ->assertOk();
+
+ $this->assertDatabaseHas('dns_manager_domain', ['id' => $domain->id, 'is_created' => DnsDomain::STATUS_CANCELLED]);
+ Queue::assertNotPushed(SuspendDnsDomainJob::class);
+ }
+
+ public function test_suspend_job_calls_plesk_for_cancelled_domain(): void
+ {
+ $domain = $this->domain();
+ $domain->update(['is_created' => DnsDomain::STATUS_CANCELLED]);
+ $plesk = Mockery::mock(PleskDomainService::class);
+ $plesk->shouldReceive('suspend')->once()->with(Mockery::any(), 'example.vn')->andReturn(true);
+
+ (new SuspendDnsDomainJob($domain->id))->handle($plesk);
+ }
+
+ public function test_suspend_job_skips_domain_reactivated_before_it_ran(): void
+ {
+ $domain = $this->domain();
+ $plesk = Mockery::mock(PleskDomainService::class);
+ $plesk->shouldNotReceive('suspend');
+
+ (new SuspendDnsDomainJob($domain->id))->handle($plesk);
+ $this->addToAssertionCount(1);
+ }
+
+ public function test_store_queues_provisioning_on_selected_server(): void
+ {
+ Queue::fake();
+
+ $id = $this->actingAs($this->admin(), 'admin')
+ ->postJson('/api/v1/administrator/domains', ['domain_name' => 'example.vn', 'dns_manager_server_id' => $this->serverId])
+ ->assertCreated()
+ ->assertJsonPath('data.provisioning_queued', true)
+ ->json('data.id');
+
+ Queue::assertPushed(ProvisionDnsDomainJob::class, fn ($job) => $job->domainId === $id);
+ }
+
+ public function test_job_creates_domain_on_plesk_when_missing(): void
+ {
+ $domain = $this->domain();
+ $plesk = Mockery::mock(PleskDomainService::class);
+ $plesk->shouldReceive('getDomainInfo')->once()->andReturn(false);
+ $plesk->shouldReceive('createDomain')->once()->with(Mockery::any(), ['domain_name' => 'example.vn'])->andReturn(['id' => 55, 'guid' => 'g']);
+
+ (new ProvisionDnsDomainJob($domain->id))->handle($plesk);
+
+ $this->assertDatabaseHas('dns_manager_domain', ['id' => $domain->id, 'plesk_id' => 55, 'is_created' => 1]);
+ }
+
+ public function test_job_links_existing_plesk_domain_without_creating(): void
+ {
+ $domain = $this->domain();
+ $plesk = Mockery::mock(PleskDomainService::class);
+ $plesk->shouldReceive('getDomainInfo')->once()->andReturn(['id' => 77, 'name' => 'example.vn']);
+ $plesk->shouldNotReceive('createDomain');
+
+ (new ProvisionDnsDomainJob($domain->id))->handle($plesk);
+
+ $this->assertDatabaseHas('dns_manager_domain', ['id' => $domain->id, 'plesk_id' => 77, 'is_created' => 1]);
+ }
+
+ public function test_job_fails_and_keeps_domain_uncreated_when_plesk_rejects(): void
+ {
+ $domain = $this->domain();
+ $plesk = Mockery::mock(PleskDomainService::class);
+ $plesk->shouldReceive('getDomainInfo')->andReturn(false);
+ $plesk->shouldReceive('createDomain')->andReturn(false);
+ $plesk->shouldReceive('getLastError')->andReturn('boom');
+
+ $this->expectExceptionMessage('boom');
+ try {
+ (new ProvisionDnsDomainJob($domain->id))->handle($plesk);
+ } finally {
+ $this->assertDatabaseHas('dns_manager_domain', ['id' => $domain->id, 'plesk_id' => null, 'is_created' => 0]);
+ }
+ }
+
+ public function test_provision_endpoint_requeues_only_uncreated_active_domains(): void
+ {
+ Queue::fake();
+ $admin = $this->admin();
+ $domain = $this->domain();
+ $url = "/api/v1/administrator/domains/{$domain->id}/provision";
+
+ $this->actingAs($admin, 'admin')->postJson($url)->assertOk();
+ Queue::assertPushed(ProvisionDnsDomainJob::class, 1);
+
+ $domain->update(['plesk_id' => 9]);
+ $this->actingAs($admin, 'admin')->postJson($url)->assertStatus(422);
+
+ $domain->update(['plesk_id' => null, 'is_created' => DnsDomain::STATUS_CANCELLED]);
+ $this->actingAs($admin, 'admin')->postJson($url)->assertStatus(422);
+
+ Queue::assertPushed(ProvisionDnsDomainJob::class, 1);
+ }
+}
diff --git a/tests/Feature/Api/Admin/Invoice/InvoiceSendMailTest.php b/tests/Feature/Api/Admin/Invoice/InvoiceSendMailTest.php
new file mode 100644
index 0000000..01b4412
--- /dev/null
+++ b/tests/Feature/Api/Admin/Invoice/InvoiceSendMailTest.php
@@ -0,0 +1,93 @@
+ 'Full Administrator', 'guard_name' => 'admin']);
+ $this->admin = User::factory()->create();
+ $this->admin->assignRole('Full Administrator');
+
+ $customer = Customer::factory()->create();
+ $order = Order::create([
+ 'order_num' => 'ORD-' . uniqid(),
+ 'customer_id' => $customer->id,
+ 'sub_total' => 100_000,
+ 'total' => 100_000,
+ 'status' => OrderStatus::PENDING->value,
+ ]);
+ $this->invoice = Invoice::create([
+ 'invoice_number' => 'INV-' . uniqid(),
+ 'customer_id' => $customer->id,
+ 'order_id' => $order->id,
+ 'invoice_type' => 1,
+ 'sub_total' => 100_000,
+ 'tax' => 0,
+ 'total' => 100_000,
+ 'status' => InvoiceStatus::UNPAID->value,
+ 'due_date' => now()->addDays(5)->toDateString(),
+ ]);
+ }
+
+ private function sendMail(bool $mailEnabled)
+ {
+ Queue::fake();
+ Setting::updateOrCreate(['key' => 'email_settings'], ['value' => json_encode(['mail_enabled' => $mailEnabled])]);
+
+ return $this->actingAs($this->admin, 'admin')
+ ->postJson("/api/v1/administrator/invoices/{$this->invoice->id}/send-mail", ['type' => 'invoice', 'message' => 'Hi']);
+ }
+
+ public function test_send_mail_is_queued(): void
+ {
+ $this->sendMail(true)->assertOk();
+
+ Queue::assertPushed(SendInvoiceMailJob::class, fn ($job) => $job->invoiceId === $this->invoice->id
+ && $job->sentBy === $this->admin->id
+ && $job->html === null);
+ }
+
+ public function test_send_mail_is_rejected_when_mail_is_disabled(): void
+ {
+ $this->sendMail(false)->assertStatus(422);
+
+ Queue::assertNothingPushed();
+ }
+
+ public function test_job_sends_invoice_mail_and_logs_it(): void
+ {
+ $this->seed(\Database\Seeders\TemplateSeeder::class);
+ Mail::fake();
+
+ dispatch_sync(new SendInvoiceMailJob($this->invoice->id, 'invoice', 'Hi', null, null, $this->admin->id));
+
+ Mail::assertSent(InvoiceNotificationMail::class);
+ $this->assertDatabaseHas('mail', ['order_id' => $this->invoice->order_id, 'sent_by' => $this->admin->id]);
+ }
+}
diff --git a/tests/Feature/Api/Client/DomainRecordPermissionTest.php b/tests/Feature/Api/Client/DomainRecordPermissionTest.php
new file mode 100644
index 0000000..08936e0
--- /dev/null
+++ b/tests/Feature/Api/Client/DomainRecordPermissionTest.php
@@ -0,0 +1,133 @@
+customer = Customer::factory()->create();
+ $serverId = DB::table('dns_manager_servers')->insertGetId(['ip_address' => '10.0.0.1', 'password' => 'x']);
+ $this->domain = DnsDomain::create(['domain_name' => 'example.vn', 'dns_manager_server_id' => $serverId, 'is_created' => 1]);
+ $this->domain->customer()->attach($this->customer->id);
+ }
+
+ private function client()
+ {
+ return $this->actingAs($this->customer, 'client')
+ ->withHeaders(['x-access-token' => $this->buildClientAccessToken()]);
+ }
+
+ private function record(string $type): DnsManagerNS
+ {
+ return DnsManagerNS::create([
+ 'dns_manager_domain_id' => $this->domain->id,
+ 'type' => $type,
+ 'type_valid' => $type,
+ 'name' => 'example.vn',
+ 'value' => 'ns1.example.net.',
+ 'ttl' => 3600,
+ ]);
+ }
+
+ public function test_customer_cannot_create_ns_record(): void
+ {
+ $this->client()
+ ->postJson("/v1/client/client-service/domains/{$this->domain->id}/records", [
+ 'type' => 'NS', 'name' => 'example.vn', 'value' => 'ns1.other.net.',
+ ])
+ ->assertStatus(422);
+ }
+
+ public function test_customer_cannot_update_or_delete_ns_record(): void
+ {
+ $ns = $this->record('NS');
+ $url = "/v1/client/client-service/domains/{$this->domain->id}/records/{$ns->id}";
+
+ $this->client()->putJson($url, ['type' => 'A', 'name' => 'www', 'value' => '1.2.3.4'])->assertStatus(403);
+ $this->client()->deleteJson($url)->assertStatus(403);
+
+ $this->assertDatabaseHas('dns_manager_ns', ['id' => $ns->id, 'type' => 'NS']);
+ }
+
+ public function test_domain_detail_exposes_only_record_fields(): void
+ {
+ $this->domain->update(['plesk_id' => 214]);
+ $this->record('A')->update(['plesk_id' => 3260]);
+
+ $data = $this->client()
+ ->getJson("/v1/client/client-service/domains/{$this->domain->id}")
+ ->assertOk()
+ ->assertJsonPath('data.domain_name', 'example.vn')
+ ->json('data');
+
+ $this->assertSame(['domain_name', 'records'], array_keys($data));
+ $this->assertSame(
+ ['id', 'name', 'type', 'priority', 'weight', 'port', 'service_name', 'protocol', 'value', 'ttl'],
+ array_keys($data['records']['items'][0]),
+ );
+ }
+
+ public function test_domain_list_merges_domain_services_and_assigned_dns_zones(): void
+ {
+ $service = Service::create(['customer_id' => $this->customer->id, 'type' => 'domain', 'server_name' => 'Example.vn', 'status' => 'active']);
+ Service::create(['customer_id' => $this->customer->id, 'type' => 'domain', 'server_name' => 'other.vn', 'status' => 'active']);
+ $zone = DnsDomain::create(['domain_name' => 'zone-only.vn', 'dns_manager_server_id' => $this->domain->dns_manager_server_id, 'is_created' => 1]);
+ $zone->customer()->attach($this->customer->id);
+
+ $items = collect($this->client()->getJson('/v1/client/client-service/domain')->assertOk()->json('data.items'))
+ ->keyBy('domain_name');
+
+ $this->assertCount(3, $items);
+ $this->assertSame($service->uuid, $items['Example.vn']['service_uuid']);
+ $this->assertSame($this->domain->id, $items['Example.vn']['dns_domain_id']);
+ $this->assertNull($items['other.vn']['dns_domain_id']);
+ $this->assertNull($items['zone-only.vn']['service_uuid']);
+ $this->assertSame($zone->id, $items['zone-only.vn']['dns_domain_id']);
+ }
+
+ public function test_cancelled_zone_is_listed_as_cancelled_without_dns_access(): void
+ {
+ $this->domain->update(['is_created' => DnsDomain::STATUS_CANCELLED]);
+
+ $this->client()->getJson('/v1/client/client-service/domain')
+ ->assertOk()
+ ->assertJsonCount(1, 'data.items')
+ ->assertJsonPath('data.items.0.cancelled', true)
+ ->assertJsonPath('data.items.0.dns_domain_id', null);
+ $this->client()->getJson("/v1/client/client-service/domains/{$this->domain->id}/records")->assertNotFound();
+ }
+
+ public function test_domain_service_detail_exposes_dns_zone_and_cancelled_state(): void
+ {
+ $service = Service::create(['customer_id' => $this->customer->id, 'type' => 'domain', 'server_name' => 'example.vn', 'status' => 'active']);
+ $url = "/v1/client/client-service/{$service->uuid}";
+
+ $this->client()->getJson($url)->assertOk()
+ ->assertJsonPath('data.dns_domain_id', $this->domain->id)
+ ->assertJsonPath('data.dns_cancelled', false);
+
+ $this->domain->update(['is_created' => DnsDomain::STATUS_CANCELLED]);
+
+ $this->client()->getJson($url)->assertOk()
+ ->assertJsonPath('data.dns_domain_id', null)
+ ->assertJsonPath('data.dns_cancelled', true);
+ }
+}
diff --git a/tests/Feature/MailEnabledToggleTest.php b/tests/Feature/MailEnabledToggleTest.php
index 6a87a61..e6399eb 100644
--- a/tests/Feature/MailEnabledToggleTest.php
+++ b/tests/Feature/MailEnabledToggleTest.php
@@ -2,12 +2,15 @@
namespace Tests\Feature;
+use App\Jobs\Mail\SendTemplateEmailJob;
use App\Mail\ExpiryService;
use App\Mail\GenericTemplateMail;
use App\Models\Setting;
+use App\Services\Email\EmailTemplateService;
use App\Support\AutoEmailGate;
use Illuminate\Foundation\Testing\DatabaseTransactions;
use Illuminate\Support\Facades\Mail;
+use Illuminate\Support\Facades\Queue;
use Tests\TestCase;
class MailEnabledToggleTest extends TestCase
@@ -72,4 +75,20 @@ class MailEnabledToggleTest extends TestCase
{
$this->assertSame('service_expiry', (new ExpiryService(0, []))->buildViewData()[AutoEmailGate::SCENARIO_DATA_KEY]);
}
+
+ public function test_template_service_does_not_queue_when_mail_disabled_or_scenario_off(): void
+ {
+ Queue::fake();
+ $service = app(EmailTemplateService::class);
+
+ $this->setMailEnabled(false);
+ $service->send('order_created', 'user@example.com');
+
+ $this->disableAuto(['order_created']);
+ $service->send('order_created', 'user@example.com');
+ Queue::assertNothingPushed();
+
+ $service->send('domain_order_approved', 'user@example.com');
+ Queue::assertPushed(SendTemplateEmailJob::class, 1);
+ }
}