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 '

    File đính kèm

    ' - . '
    '; - } - 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); + } }