diff --git a/app/Http/Controllers/Api/Admin/Customer/Concerns/ManagesCustomerServices.php b/app/Http/Controllers/Api/Admin/Customer/Concerns/ManagesCustomerServices.php index 9a3e224..ab3470c 100644 --- a/app/Http/Controllers/Api/Admin/Customer/Concerns/ManagesCustomerServices.php +++ b/app/Http/Controllers/Api/Admin/Customer/Concerns/ManagesCustomerServices.php @@ -17,6 +17,7 @@ use Illuminate\Support\Facades\Auth; use Illuminate\Validation\Rule; use App\Support\BillingCycleCatalog; use App\Support\RenewalPeriodGuard; +use App\Support\ServiceProvisionLog; trait ManagesCustomerServices { @@ -134,6 +135,19 @@ trait ManagesCustomerServices return response()->json(['error' => false, 'data' => $renewals]); } + public function serviceProvisioningLogs(string $uuid, int $serviceId): JsonResponse + { + $service = Service::where('customer_id', $this->resolveCustomer($uuid)->id)->findOrFail($serviceId); + + $logs = ActivityLog::where('subject_type', Service::class) + ->where('subject_id', $service->id) + ->whereIn('event', ServiceProvisionLog::EVENTS) + ->orderBy('id') + ->get(['id', 'event', 'description', 'causer_type', 'causer_name', 'new_values', 'created_at']); + + return response()->json(['error' => false, 'data' => $logs]); + } + public function renewService(string $uuid, int $serviceId, Request $request): JsonResponse { $data = $request->validate([ diff --git a/app/Http/Controllers/Api/Admin/Customer/CustomerIdentityController.php b/app/Http/Controllers/Api/Admin/Customer/CustomerIdentityController.php new file mode 100644 index 0000000..78d5459 --- /dev/null +++ b/app/Http/Controllers/Api/Admin/Customer/CustomerIdentityController.php @@ -0,0 +1,24 @@ +json([ + 'error' => false, + 'data' => CustomerIdentity::payload(Customer::where('uuid', $uuid)->firstOrFail()), + ]); + } + + public function image(Customer $customer, string $side) + { + return CustomerIdentity::response($customer, $side); + } +} diff --git a/app/Http/Controllers/Api/Admin/EmailTemplate/EmailTemplateController.php b/app/Http/Controllers/Api/Admin/EmailTemplate/EmailTemplateController.php index 9f1b5bf..59ed141 100644 --- a/app/Http/Controllers/Api/Admin/EmailTemplate/EmailTemplateController.php +++ b/app/Http/Controllers/Api/Admin/EmailTemplate/EmailTemplateController.php @@ -128,6 +128,7 @@ class EmailTemplateController extends Controller 'message' => 'nullable|string', 'builder_data' => 'nullable|array', 'status' => 'boolean', + 'marketing' => 'nullable|boolean', 'mail_config' => 'nullable|array', 'mail_config.from_email' => 'nullable|email', 'mail_config.from_name' => 'nullable|string|max:255', @@ -151,7 +152,10 @@ class EmailTemplateController extends Controller 'message' => $data['message'] ?? null, 'builder_data' => $data['builder_data'] ?? null, 'status' => $data['status'] ?? 1, - 'metadata' => isset($data['mail_config']) ? ['mail' => $data['mail_config']] : null, + 'metadata' => array_filter([ + 'mail' => $data['mail_config'] ?? null, + 'marketing' => isset($data['marketing']) ? (bool) $data['marketing'] : null, + ], fn ($value) => $value !== null) ?: null, ]); $this->syncVariables($template, $data['variables'] ?? []); $guard->assertCanSave($template); @@ -177,6 +181,7 @@ class EmailTemplateController extends Controller 'message' => 'sometimes|nullable|string', 'builder_data' => 'sometimes|nullable|array', 'status' => 'sometimes|boolean', + 'marketing' => 'sometimes|boolean', 'mail_config' => 'sometimes|nullable|array', 'mail_config.from_email' => 'nullable|email', 'mail_config.from_name' => 'nullable|string|max:255', @@ -194,8 +199,11 @@ class EmailTemplateController extends Controller $metadata['mail'] = $data['mail_config'] ?? []; $data['metadata'] = $metadata; } + if (array_key_exists('marketing', $data)) { + $data['metadata'] = [...($data['metadata'] ?? $template->metadata ?? []), 'marketing' => (bool) $data['marketing']]; + } $variables = $data['variables'] ?? []; - unset($data['mail_config'], $data['variables']); + unset($data['mail_config'], $data['variables'], $data['marketing']); $template->fill($data); $this->syncVariables($template, $variables); @@ -401,7 +409,8 @@ class EmailTemplateController extends Controller 'variables' => array_values($t->variables()), 'mail_config' => $t->mailConfig(), 'placeholders' => $this->placeholders($t), - ...collect(EmailScenarioRegistry::describe($t->key, $t->scenario))->only(['registered', 'category', 'group', 'permissions', 'required_variables'])->all(), + ...collect(EmailScenarioRegistry::describe($t->key, $t->scenario))->only(['registered', 'group', 'permissions', 'required_variables'])->all(), + 'category' => $t->category(), 'global_variables' => collect(MailGlobals::LABELS)->map(fn ($label, $key) => ['key' => $key, 'label' => $label])->values(), 'created_at' => $t->created_at, 'updated_at' => $t->updated_at, diff --git a/app/Http/Controllers/Api/Admin/Hosting/ServicePlanController.php b/app/Http/Controllers/Api/Admin/Hosting/ServicePlanController.php index c366640..3bba41f 100644 --- a/app/Http/Controllers/Api/Admin/Hosting/ServicePlanController.php +++ b/app/Http/Controllers/Api/Admin/Hosting/ServicePlanController.php @@ -13,14 +13,14 @@ class ServicePlanController extends Controller { public function index(): JsonResponse { - $plans = ServicePlan::with('plesk', 'dnsServer')->orderBy('sort_order')->orderBy('title')->get(); + $plans = ServicePlan::with('plesk', 'servers')->orderBy('sort_order')->orderBy('title')->get(); return response()->json(['error' => false, 'data' => $plans]); } public function show(int $id): JsonResponse { - $plan = ServicePlan::with('plesk', 'dnsServer')->find($id); + $plan = ServicePlan::with('plesk', 'servers')->find($id); if (!$plan) { return response()->json(['error' => true, 'message' => 'Không tìm thấy gói hosting.'], 404); @@ -33,12 +33,13 @@ class ServicePlanController extends Controller { $plan = ServicePlan::create($request->planAttributes()); + $plan->syncServers($request->serverIds()); $this->savePlesk($plan, $request->validated()); return response()->json([ 'error' => false, 'message' => 'Tạo gói hosting thành công.', - 'data' => $plan->load('plesk', 'dnsServer'), + 'data' => $plan->load('plesk', 'servers'), ], 201); } @@ -52,12 +53,13 @@ class ServicePlanController extends Controller $plan->update($request->planAttributes()); + $plan->syncServers($request->serverIds()); $this->savePlesk($plan, $request->validated()); return response()->json([ 'error' => false, 'message' => 'Cập nhật gói hosting thành công.', - 'data' => $plan->fresh(['plesk', 'dnsServer']), + 'data' => $plan->fresh(['plesk', 'servers']), ]); } diff --git a/app/Http/Controllers/Api/Admin/MailCampaign/Concerns/HandlesCampaignBulkActions.php b/app/Http/Controllers/Api/Admin/MailCampaign/Concerns/HandlesCampaignBulkActions.php index a278fc8..87d7e49 100644 --- a/app/Http/Controllers/Api/Admin/MailCampaign/Concerns/HandlesCampaignBulkActions.php +++ b/app/Http/Controllers/Api/Admin/MailCampaign/Concerns/HandlesCampaignBulkActions.php @@ -74,7 +74,7 @@ trait HandlesCampaignBulkActions $failedLogs = Mail::where('campaign_id', $campaign->id) ->where('status', array_search('failed', Mail::STATUS_MAP, true)) ->lockForUpdate() - ->get(['id', 'to', 'customer_id']); + ->get(['id', 'to', 'customer_id', 'service_id']); if ($failedLogs->isEmpty()) { return; } @@ -87,7 +87,7 @@ trait HandlesCampaignBulkActions ])->save(); foreach ($failedLogs as $log) { - SendMailCampaignRecipient::dispatch($campaign->id, $log->customer_id, $log->to)->afterCommit(); + SendMailCampaignRecipient::dispatch($campaign->id, $log->customer_id, $log->to, $log->service_id)->afterCommit(); } $retried += $failedLogs->count(); }); diff --git a/app/Http/Controllers/Api/Admin/MailCampaign/Concerns/HandlesRenewalCampaigns.php b/app/Http/Controllers/Api/Admin/MailCampaign/Concerns/HandlesRenewalCampaigns.php new file mode 100644 index 0000000..26c9007 --- /dev/null +++ b/app/Http/Controllers/Api/Admin/MailCampaign/Concerns/HandlesRenewalCampaigns.php @@ -0,0 +1,71 @@ +validate([ + 'uuids' => 'required|array|min:1|max:200', + 'uuids.*' => 'string|exists:customers,uuid', + 'days' => 'nullable|integer|min:1|max:365', + ]); + + $customerIds = Customer::whereIn('uuid', $data['uuids'])->pluck('id')->all(); + $today = now()->startOfDay(); + + $rows = RenewalQuote::dueServices($customerIds, (int) ($data['days'] ?? 30)) + ->map(function (Service $service) use ($today) { + $quote = RenewalQuote::forService($service); + + return $quote ? [ + ...$quote, + 'customer_uuid' => $service->customer->uuid, + 'customer_name' => $service->customer->fullname, + 'customer_email' => $service->customer->email, + 'days_left' => (int) $today->diffInDays($service->expires_at->copy()->startOfDay(), false), + ] : null; + }) + ->filter() + ->values(); + + return response()->json(['error' => false, 'data' => $rows]); + } + + /** + * @param array $selected + * @return array + */ + private function renewalJobs(MailCampaign $campaign, array $selected): array + { + if (! RenewalCampaign::isRenewalTemplate($campaign)) { + throw ValidationException::withMessages(['renewal_services' => 'Template đã chọn không phải mẫu nhắc gia hạn.']); + } + + $services = Service::with('customer:id,email') + ->whereIn('id', array_column($selected, 'service_id')) + ->get(); + + return $services + ->filter(fn (Service $service) => $service->customer && trim((string) $service->customer->email) !== '') + ->map(fn (Service $service) => [ + 'customer_id' => $service->customer->id, + 'email' => trim($service->customer->email), + 'service_id' => $service->id, + ]) + ->values() + ->all(); + } +} diff --git a/app/Http/Controllers/Api/Admin/MailCampaign/MailCampaignController.php b/app/Http/Controllers/Api/Admin/MailCampaign/MailCampaignController.php index c716782..d2df763 100644 --- a/app/Http/Controllers/Api/Admin/MailCampaign/MailCampaignController.php +++ b/app/Http/Controllers/Api/Admin/MailCampaign/MailCampaignController.php @@ -5,6 +5,7 @@ declare(strict_types=1); namespace App\Http\Controllers\Api\Admin\MailCampaign; use App\Http\Controllers\Api\Admin\MailCampaign\Concerns\HandlesCampaignBulkActions; +use App\Http\Controllers\Api\Admin\MailCampaign\Concerns\HandlesRenewalCampaigns; use App\Http\Controllers\Controller; use App\Jobs\Mail\SendMailCampaignRecipient; use App\Models\Customer; @@ -16,6 +17,7 @@ use App\Support\AutoEmailGate; use App\Support\MailAttachments; use App\Support\MailPlaceholders; use App\Support\MailSettings; +use App\Support\RenewalCampaign; use Illuminate\Http\JsonResponse; use Illuminate\Http\Request; use Illuminate\Support\Collection; @@ -28,6 +30,7 @@ use Illuminate\Validation\ValidationException; final class MailCampaignController extends Controller { use HandlesCampaignBulkActions; + use HandlesRenewalCampaigns; private const MAX_EMAILS = 1000; @@ -253,6 +256,9 @@ final class MailCampaignController extends Controller 'emails' => 'nullable|array|max:' . self::MAX_EMAILS, 'emails.*' => self::EMAIL_RULE, 'name' => 'nullable|string|max:255', + 'renewal_services' => 'nullable|array|max:500', + 'renewal_services.*.service_id' => 'required|integer|exists:services,id', + 'renewal_services.*.months' => 'required|integer|min:1|max:600', 'cc' => 'nullable|string|max:1000', 'bcc' => 'nullable|string|max:1000', ...MailAttachments::rules(), @@ -263,12 +269,25 @@ final class MailCampaignController extends Controller $campaign = $this->draftCampaign($data); - $marketingOnly = $campaign->isMarketing(); - $recipients = $data['recipient_type'] === 'all' - ? $this->allCustomerRecipients($marketingOnly) - : $this->selectedRecipients($data['uuids'] ?? [], $data['emails'] ?? [], $marketingOnly); - $optedOut = $recipients['opted_out']; - $recipients = $recipients['recipients']; + $renewalJobs = []; + $optedOut = 0; + if (!empty($data['renewal_services'])) { + $renewalJobs = $this->renewalJobs($campaign, $data['renewal_services']); + $campaign->renewal_services = array_values(array_filter( + $data['renewal_services'], + fn (array $item) => in_array((int) $item['service_id'], array_column($renewalJobs, 'service_id'), true), + )); + $campaign->variables = RenewalCampaign::withoutServiceValues($campaign->variables); + $data['recipient_type'] = 'selected'; + $recipients = $renewalJobs; + } else { + $marketingOnly = $campaign->isMarketing(); + $resolved = $data['recipient_type'] === 'all' + ? $this->allCustomerRecipients($marketingOnly) + : $this->selectedRecipients($data['uuids'] ?? [], $data['emails'] ?? [], $marketingOnly); + $optedOut = $resolved['opted_out']; + $recipients = $resolved['recipients']; + } if ($recipients === []) { return response()->json([ @@ -299,8 +318,14 @@ final class MailCampaignController extends Controller ]); } - foreach ($recipients as $email => $customerId) { - SendMailCampaignRecipient::dispatch($campaign->id, $customerId, $email); + if ($renewalJobs !== []) { + foreach ($renewalJobs as $job) { + SendMailCampaignRecipient::dispatch($campaign->id, $job['customer_id'], $job['email'], $job['service_id']); + } + } else { + foreach ($recipients as $email => $customerId) { + SendMailCampaignRecipient::dispatch($campaign->id, $customerId, $email); + } } return response()->json([ diff --git a/app/Http/Controllers/Api/Client/Concerns/HandlesClientAuthentication.php b/app/Http/Controllers/Api/Client/Concerns/HandlesClientAuthentication.php index 708f953..981d6f9 100644 --- a/app/Http/Controllers/Api/Client/Concerns/HandlesClientAuthentication.php +++ b/app/Http/Controllers/Api/Client/Concerns/HandlesClientAuthentication.php @@ -51,7 +51,7 @@ trait HandlesClientAuthentication if ($twoFactor->requiresChallenge($client, $request->input('device_token'))) { return response()->json([ 'error' => false, - 'data' => ['two_factor' => $twoFactor->startLoginChallenge($client)], + 'data' => ['two_factor' => $twoFactor->startLoginChallenge($client, $request->ip(), $request->userAgent())], ]); } diff --git a/app/Http/Controllers/Api/Client/Service/ServiceRenewalOptionsController.php b/app/Http/Controllers/Api/Client/Service/ServiceRenewalOptionsController.php new file mode 100644 index 0000000..1598341 --- /dev/null +++ b/app/Http/Controllers/Api/Client/Service/ServiceRenewalOptionsController.php @@ -0,0 +1,30 @@ +where('uuid', $uuid) + ->where('customer_id', $request->user()->id) + ->firstOrFail(); + + $source = $service->customProduct ?? $service->orderItem?->product ?? $service->product; + + return response()->json([ + 'error' => false, + 'data' => [ + 'options' => RenewalPricing::options($source), + 'addon_total' => RenewalPricing::addonTotal($service, 1), + ], + ]); + } +} diff --git a/app/Http/Requests/Admin/Hosting/ServicePlanRequest.php b/app/Http/Requests/Admin/Hosting/ServicePlanRequest.php index 0ade686..2659da3 100644 --- a/app/Http/Requests/Admin/Hosting/ServicePlanRequest.php +++ b/app/Http/Requests/Admin/Hosting/ServicePlanRequest.php @@ -34,7 +34,8 @@ class ServicePlanRequest extends FormRequest 'features.*' => 'string|distinct|max:100', 'is_active' => 'boolean', 'sort_order' => 'nullable|integer|min:0', - 'dns_manager_server_id' => 'nullable|integer|exists:dns_manager_servers,id', + 'server_ids' => 'nullable|array|max:10', + 'server_ids.*' => 'integer|distinct|exists:dns_manager_servers,id', 'plesk_plan_name' => 'nullable|string|max:255', 'note' => 'nullable|string', ]; @@ -48,10 +49,17 @@ class ServicePlanRequest extends FormRequest $validated = $this->validated(); return [ - ...collect($validated)->except(['plesk_plan_name', 'note'])->all(), - 'dns_manager_server_id' => $validated['dns_manager_server_id'] ?? null, + ...collect($validated)->except(['plesk_plan_name', 'note', 'server_ids'])->all(), 'sort_order' => $validated['sort_order'] ?? 0, 'is_active' => $validated['is_active'] ?? true, ]; } + + /** + * @return array + */ + public function serverIds(): array + { + return $this->validated()['server_ids'] ?? []; + } } diff --git a/app/Http/Requests/Api/Client/ClientUpdateCccdRequest.php b/app/Http/Requests/Api/Client/ClientUpdateCccdRequest.php index 4f98b1f..645cefc 100644 --- a/app/Http/Requests/Api/Client/ClientUpdateCccdRequest.php +++ b/app/Http/Requests/Api/Client/ClientUpdateCccdRequest.php @@ -14,9 +14,9 @@ class ClientUpdateCccdRequest extends FormRequest public function rules(): array { return [ - 'cmnd' => 'required|string|max:50', - 'cmnd_before' => 'required|mimes:jpg,jpeg,png,webp|max:5120', - 'cmnd_after' => 'required|mimes:jpg,jpeg,png,webp|max:5120', + 'cmnd' => 'required|string|regex:/^\d{9}(\d{3})?$/', + 'cmnd_before' => 'required|file|mimetypes:image/webp|mimes:webp|max:5120|dimensions:min_width=300,min_height=300,max_width=2000,max_height=2000', + 'cmnd_after' => 'required|file|mimetypes:image/webp|mimes:webp|max:5120|dimensions:min_width=300,min_height=300,max_width=2000,max_height=2000', ]; } @@ -24,10 +24,17 @@ class ClientUpdateCccdRequest extends FormRequest { return [ 'cmnd.required' => 'Số CMT/CCCD là bắt buộc', + 'cmnd.regex' => 'Số CMT/CCCD phải gồm 9 hoặc 12 chữ số', 'cmnd_before.required' => 'Ảnh mặt trước là bắt buộc', - 'cmnd_before.image' => 'Ảnh mặt trước phải là hình ảnh', + 'cmnd_before.mimetypes' => 'Ảnh mặt trước phải là ảnh WebP', + 'cmnd_before.mimes' => 'Ảnh mặt trước phải là ảnh WebP', + 'cmnd_before.max' => 'Ảnh mặt trước tối đa 5MB', + 'cmnd_before.dimensions' => 'Ảnh mặt trước phải từ 300px đến 2000px mỗi cạnh', 'cmnd_after.required' => 'Ảnh mặt sau là bắt buộc', - 'cmnd_after.image' => 'Ảnh mặt sau phải là hình ảnh', + 'cmnd_after.mimetypes' => 'Ảnh mặt sau phải là ảnh WebP', + 'cmnd_after.mimes' => 'Ảnh mặt sau phải là ảnh WebP', + 'cmnd_after.max' => 'Ảnh mặt sau tối đa 5MB', + 'cmnd_after.dimensions' => 'Ảnh mặt sau phải từ 300px đến 2000px mỗi cạnh', ]; } } diff --git a/app/Http/Resources/Api/Client/Service/ServiceClientDetailResource.php b/app/Http/Resources/Api/Client/Service/ServiceClientDetailResource.php index 5d749cb..8bd8c16 100644 --- a/app/Http/Resources/Api/Client/Service/ServiceClientDetailResource.php +++ b/app/Http/Resources/Api/Client/Service/ServiceClientDetailResource.php @@ -98,7 +98,6 @@ class ServiceClientDetailResource extends JsonResource $vm = $this->resource->vmware; $product = $orderItem?->product ?? $this->resource->product; $custom = $this->resource->customProduct; - $renewalSource = $custom ?? $product; $hosting = $this->resource->hostingAccount; $pleskCredentials = $hosting ? $this->resolvePleskCredentials($hosting) : null; $dnsZone = $this->resolveDnsZone(); @@ -124,14 +123,6 @@ class ServiceClientDetailResource extends JsonResource 'total' => (float) $order->total, 'months' => (int) $orderItem->months, 'billing_label' => $orderItem->billing_label, - 'cycle_price' => $custom - ? (float) $custom->unit_price * (int) $orderItem->months - : (float) ($product?->pricings() - ->where('billing_cycle', $orderItem->billing_cycle) - ->where('is_active', 1) - ->value('price') ?? 0), - 'addon_total' => \App\Support\RenewalPricing::addonTotal($this->resource, 1), - 'renewal_options' => \App\Support\RenewalPricing::options($renewalSource), ] : null, 'vm' => $vm ? [ 'ip_address' => $vm->ipAddress?->ip ?? $vm->ip, @@ -145,6 +136,7 @@ class ServiceClientDetailResource extends JsonResource ] : null, 'hosting' => $hosting ? [ 'ip_address' => $hosting->server?->publicHost(), + 'server_ip' => $hosting->server?->ip_address, 'plesk_url' => $hosting->server?->panelUrl(), 'username' => ResponseCipher::encrypt($pleskCredentials['login'] ?? null), 'password' => ResponseCipher::encrypt($pleskCredentials['password'] ?? null), diff --git a/app/Jobs/Hosting/ProvisionHostingAccountJob.php b/app/Jobs/Hosting/ProvisionHostingAccountJob.php index 46930bf..1aec9b3 100644 --- a/app/Jobs/Hosting/ProvisionHostingAccountJob.php +++ b/app/Jobs/Hosting/ProvisionHostingAccountJob.php @@ -7,6 +7,8 @@ namespace App\Jobs\Hosting; use App\Models\Invoice; use App\Models\OrderItem; use App\Models\ServicePlan; +use App\Services\Hosting\HostingProvisionFailure; +use App\Services\Hosting\HostingServerSelector; use App\Services\Plesk\PleskHostingService; use Illuminate\Bus\Queueable; use Illuminate\Contracts\Queue\ShouldQueue; @@ -14,6 +16,8 @@ use Illuminate\Foundation\Bus\Dispatchable; use Illuminate\Queue\InteractsWithQueue; use Illuminate\Queue\SerializesModels; use Illuminate\Support\Facades\Log; +use Throwable; +use App\Support\ServiceProvisionLog; /** * Auto-provisions a Plesk hosting account when a hosting order is paid, @@ -26,97 +30,89 @@ class ProvisionHostingAccountJob implements ShouldQueue { use Dispatchable, InteractsWithQueue, Queueable, SerializesModels; - /** Plesk subscription creation can take a couple of minutes */ public int $timeout = 300; - /** No auto-retry — hosting provisioning errors need manual review */ public int $tries = 1; public function __construct(public OrderItem $item) { } - public function handle(PleskHostingService $plesk): void + public function handle(PleskHostingService $plesk, HostingServerSelector $servers, HostingProvisionFailure $failure): void + { + try { + $error = $this->provision($plesk, $servers, $failure); + } catch (Throwable $e) { + $error = $e->getMessage(); + } + + if ($error !== null) { + $failure->handle($this->item, $error); + } + } + + private function provision(PleskHostingService $plesk, HostingServerSelector $servers, HostingProvisionFailure $failure): ?string { $service = $this->item->service()->first(); $domainName = $this->item->server_name; if (! $service || ! $domainName) { - Log::channel('Custom')->warning('ProvisionHostingAccountJob skipped: missing service or domain', [ - 'order_item_id' => $this->item->id, - ]); - - return; + return 'Không tìm thấy dịch vụ hoặc tên miền của đơn hàng.'; } $servicePlanId = $this->item->product?->service_plan_id; - $servicePlan = $servicePlanId - ? ServicePlan::with(['plesk', 'dnsServer'])->find($servicePlanId) - : null; - + $servicePlan = $servicePlanId ? ServicePlan::with('plesk')->find($servicePlanId) : null; $hostingPlan = $servicePlan?->plesk?->plesk_plan_name; - $server = $servicePlan?->dnsServer; Log::channel('Custom')->info('ProvisionHostingAccountJob resolved service plan', [ - 'order_item_id' => $this->item->id, - 'product_id' => $this->item->product_id, - 'product_name' => $this->item->product?->name, - 'service_plan_id' => $servicePlanId, - 'plesk_plan_name' => $hostingPlan, - 'dns_manager_server_id' => $server?->id, - 'dns_manager_server_ip' => $server?->ip_address, + 'order_item_id' => $this->item->id, + 'product_id' => $this->item->product_id, + 'product_name' => $this->item->product?->name, + 'service_plan_id' => $servicePlanId, + 'plesk_plan_name' => $hostingPlan, ]); if (! $hostingPlan) { - Log::channel('Custom')->error('ProvisionHostingAccountJob failed: no service_plan_plesk mapping for this product', [ - 'order_item_id' => $this->item->id, - 'product_id' => $this->item->product_id, - 'service_plan_id' => $servicePlanId, - ]); - - return; + return 'Sản phẩm chưa gắn gói Plesk (service_plan_plesk).'; } - if (! $server) { - Log::channel('Custom')->error('ProvisionHostingAccountJob failed: service plan has no dns_manager_server_id configured', [ - 'order_item_id' => $this->item->id, - 'product_id' => $this->item->product_id, - 'service_plan_id' => $servicePlanId, - ]); + ServiceProvisionLog::record($service, 'provision_started', 'Bắt đầu khởi tạo hosting', ['domain' => $domainName, 'plesk_plan' => $hostingPlan]); - return; - } + $invoiceId = Invoice::where('order_id', $this->item->order_id)->where('invoice_type', 1)->orderBy('id')->value('id'); + $triedServerIds = []; + $lastError = null; - if (! $server->servesHosting()) { - Log::channel('Custom')->error('ProvisionHostingAccountJob failed: configured server does not serve hosting', [ - 'order_item_id' => $this->item->id, - 'service_plan_id' => $servicePlanId, + while ($server = $servers->pick($servicePlan, $triedServerIds)) { + $triedServerIds[] = $server->id; + + $result = $plesk->provision([ + 'customer_id' => $this->item->order->customer_id, + 'service_id' => $service->id, 'dns_manager_server_id' => $server->id, - 'server_type' => $server->type, + 'domain_name' => $domainName, + 'hosting_plan' => $hostingPlan, + 'invoice_id' => $invoiceId, ]); - return; + if (! $result['error']) { + $service->update(['status' => 'active']); + ServiceProvisionLog::record($service, 'provision_completed', 'Khởi tạo hoàn tất — dịch vụ đã hoạt động', ['server_id' => $server->id]); + + return null; + } + + $lastError = $result['message'] ?? 'Plesk trả về lỗi không rõ.'; + $servers->forget($server); + + if ($servers->isHealthy($server)) { + return $lastError; + } + + $failure->serverFailedOver($this->item, $server, $lastError); } - $result = $plesk->provision([ - 'customer_id' => $this->item->order->customer_id, - 'service_id' => $service->id, - 'dns_manager_server_id' => $server->id, - 'domain_name' => $domainName, - 'hosting_plan' => $hostingPlan, - 'invoice_id' => Invoice::where('order_id', $this->item->order_id)->where('invoice_type', 1)->orderBy('id')->value('id'), - ]); - - if ($result['error']) { - Log::channel('Custom')->error('ProvisionHostingAccountJob failed', [ - 'order_item_id' => $this->item->id, - 'hosting_plan' => $hostingPlan, - 'message' => $result['message'] ?? 'Unknown error', - ]); - - return; - } - - $service->update(['status' => 'active']); + return $lastError === null + ? 'Gói hosting không có server Plesk nào đang hoạt động.' + : "Tất cả server Plesk của gói đều lỗi. Lỗi cuối: {$lastError}"; } } diff --git a/app/Jobs/Mail/SendMailCampaignRecipient.php b/app/Jobs/Mail/SendMailCampaignRecipient.php index 37e323d..c60b502 100644 --- a/app/Jobs/Mail/SendMailCampaignRecipient.php +++ b/app/Jobs/Mail/SendMailCampaignRecipient.php @@ -9,6 +9,7 @@ use App\Models\MailCampaign; use App\Models\Notification; use App\Services\Email\MailCampaignRenderer; use App\Support\EmailRecipients; +use App\Support\RenewalCampaign; use Illuminate\Bus\Queueable; use Illuminate\Contracts\Queue\ShouldQueue; use Illuminate\Foundation\Bus\Dispatchable; @@ -27,6 +28,7 @@ class SendMailCampaignRecipient implements ShouldQueue private readonly int $campaignId, private readonly ?int $customerId, private readonly string $email, + private readonly ?int $serviceId = null, ) {} public function handle(MailCampaignRenderer $renderer): void @@ -43,7 +45,29 @@ class SendMailCampaignRecipient implements ShouldQueue return; } - $rendered = $renderer->render($campaign, $renderer->recipientValues($campaign, $customer, $this->email)); + $values = $renderer->recipientValues($campaign, $customer, $this->email); + if ($this->serviceId !== null) { + $renewal = $customer ? RenewalCampaign::valuesFor($campaign, $customer, $this->serviceId) : null; + if ($renewal === null) { + MailLog::create([ + 'campaign_id' => $campaign->id, + 'template_id' => $campaign->template_id ?? 0, + 'subject' => $campaign->subject, + 'to' => $this->email, + 'customer_id' => $customer?->id, + 'service_id' => $this->serviceId, + 'sent_by' => $campaign->created_by, + 'message' => 'Không gửi: dịch vụ không còn thuộc khách hàng hoặc không thể tính giá gia hạn.', + 'status' => 'failed', + ]); + $this->markDone($campaign, true); + + return; + } + $values = [...$values, ...$renewal]; + } + + $rendered = $renderer->render($campaign, $values); $cc = EmailRecipients::parse($campaign->cc); $bcc = EmailRecipients::parse($campaign->bcc); $mailConfig = Arr::only($campaign->template?->mailConfig() ?? [], ['from_email', 'from_name', 'reply_to', 'reply_to_name']); @@ -81,6 +105,7 @@ class SendMailCampaignRecipient implements ShouldQueue 'cc' => implode(', ', $cc), 'bcc' => implode(', ', $bcc), 'customer_id' => $customer?->id, + 'service_id' => $this->serviceId, 'sent_by' => $campaign->created_by, 'message' => $rendered['html'], 'status' => $failed ? 'failed' : 'sent', diff --git a/app/Jobs/VirtualMachine/Create/CreateVirtualMachineJob.php b/app/Jobs/VirtualMachine/Create/CreateVirtualMachineJob.php index a75d5e6..30bd4ef 100644 --- a/app/Jobs/VirtualMachine/Create/CreateVirtualMachineJob.php +++ b/app/Jobs/VirtualMachine/Create/CreateVirtualMachineJob.php @@ -28,6 +28,7 @@ use Illuminate\Queue\InteractsWithQueue; use Illuminate\Queue\SerializesModels; use RuntimeException; use Throwable; +use App\Support\ServiceProvisionLog; class CreateVirtualMachineJob implements ShouldQueue { @@ -70,6 +71,8 @@ class CreateVirtualMachineJob implements ShouldQueue ['customer_id' => $customerId, 'status' => 'running'], ); + ServiceProvisionLog::forOrderItem($this->item->id, 'provision_started', $this->isRetry ? 'Bắt đầu khởi tạo lại máy chủ' : 'Bắt đầu khởi tạo máy chủ', ['is_retry' => $this->isRetry]); + $vmLogger->step('Create VM job started', [ 'order_item_id' => $this->item->id, 'is_retry' => $this->isRetry, @@ -110,6 +113,8 @@ class CreateVirtualMachineJob implements ShouldQueue $this->item->server_name = $combinedName; + ServiceProvisionLog::forOrderItem($this->item->id, 'provision_step', 'Đã giữ IP và bắt đầu clone VM', ['ip' => $reservedIp]); + $vmLogger->step('IP reserved for VM', [ 'order_item_id' => $this->item->id, 'ip' => $reservedIp, @@ -226,6 +231,8 @@ class CreateVirtualMachineJob implements ShouldQueue $adminDetail = $e instanceof VmProvisionException ? $e->adminDetail : $e->getMessage(); + ServiceProvisionLog::forOrderItem($this->item->id, 'provision_failed', 'Khởi tạo máy chủ thất bại — ' . $adminDetail, ['stage' => 'create_vm']); + $vmLogger->exception('Create VM job failed', $e, [ 'order_item_id' => $this->item->id, 'is_retry' => $this->isRetry, diff --git a/app/Jobs/VirtualMachine/Provision/AssignIpToGuestJob.php b/app/Jobs/VirtualMachine/Provision/AssignIpToGuestJob.php index 3879621..fec3b1c 100644 --- a/app/Jobs/VirtualMachine/Provision/AssignIpToGuestJob.php +++ b/app/Jobs/VirtualMachine/Provision/AssignIpToGuestJob.php @@ -22,6 +22,7 @@ use Illuminate\Queue\InteractsWithQueue; use Illuminate\Queue\SerializesModels; use Illuminate\Support\Facades\DB; use Illuminate\Support\Facades\Log; +use App\Support\ServiceProvisionLog; use App\Jobs\VirtualMachine\Provision\Concerns\ManagesIpReservation; /** @@ -228,6 +229,8 @@ class AssignIpToGuestJob implements ShouldQueue 'attempt' => $attempt, ]); + ServiceProvisionLog::forVm($this->moduleVmwareId, 'provision_step', 'Đã gán IP cho máy chủ', ['ip' => $ip]); + // CPU/RAM config was already applied by UpdateVmConfigJob before IP assignment. ChangePasswordViaVsphereJob::dispatch( $this->moduleVmwareId, @@ -247,6 +250,7 @@ class AssignIpToGuestJob implements ShouldQueue ModuleVmware::where('id', $this->moduleVmwareId)->update(['status' => 0]); Service::where('vm_id', $this->moduleVmwareId)->update(['status' => 'failed']); + ServiceProvisionLog::forVm($this->moduleVmwareId, 'provision_failed', "Gán IP thất bại sau {$maxAttempts} lần thử", ['stage' => 'assign_ip']); // Notify admin via Telegram with order/customer context try { @@ -311,6 +315,7 @@ class AssignIpToGuestJob implements ShouldQueue ModuleVmware::where('id', $this->moduleVmwareId)->update(['status' => 0]); Service::where('vm_id', $this->moduleVmwareId)->update(['status' => 'failed']); + ServiceProvisionLog::forVm($this->moduleVmwareId, 'provision_failed', 'Gán IP thất bại — ' . $e->getMessage(), ['stage' => 'assign_ip']); } // ───────────────────────────────────────────────────────────────────────── diff --git a/app/Jobs/VirtualMachine/Provision/ChangePasswordViaVsphereJob.php b/app/Jobs/VirtualMachine/Provision/ChangePasswordViaVsphereJob.php index e204589..adb36d4 100644 --- a/app/Jobs/VirtualMachine/Provision/ChangePasswordViaVsphereJob.php +++ b/app/Jobs/VirtualMachine/Provision/ChangePasswordViaVsphereJob.php @@ -16,6 +16,7 @@ use Illuminate\Foundation\Bus\Dispatchable; use Illuminate\Queue\InteractsWithQueue; use Illuminate\Queue\SerializesModels; use Illuminate\Support\Facades\Log; +use App\Support\ServiceProvisionLog; /** * ChangePasswordViaVsphereJob @@ -91,6 +92,8 @@ class ChangePasswordViaVsphereJob implements ShouldQueue // Provisioning complete — activate the service record Service::where('vm_id', $this->moduleVmwareId)->update(['status' => 'active']); + ServiceProvisionLog::forVm($this->moduleVmwareId, 'provision_step', 'Đã đặt mật khẩu máy chủ'); + // [8] Send credentials email — IP + new password are both ready at this point SendVmCredentialsEmailJob::dispatch($this->moduleVmwareId); @@ -112,6 +115,7 @@ class ChangePasswordViaVsphereJob implements ShouldQueue ModuleVmware::where('id', $this->moduleVmwareId)->update(['status' => 0]); Service::where('vm_id', $this->moduleVmwareId)->update(['status' => 'failed']); + ServiceProvisionLog::forVm($this->moduleVmwareId, 'provision_failed', 'Đặt mật khẩu máy chủ thất bại — ' . $e->getMessage(), ['stage' => 'set_password']); } // ───────────────────────────────────────────────────────────────────────── diff --git a/app/Jobs/VirtualMachine/Provision/RetryProvisioningOrchestrator.php b/app/Jobs/VirtualMachine/Provision/RetryProvisioningOrchestrator.php index a62f5e3..6396683 100644 --- a/app/Jobs/VirtualMachine/Provision/RetryProvisioningOrchestrator.php +++ b/app/Jobs/VirtualMachine/Provision/RetryProvisioningOrchestrator.php @@ -18,6 +18,7 @@ use Illuminate\Queue\InteractsWithQueue; use Illuminate\Queue\SerializesModels; use Illuminate\Support\Facades\DB; use Illuminate\Support\Facades\Log; +use App\Support\ServiceProvisionLog; /** * RetryProvisioningOrchestrator @@ -337,6 +338,8 @@ class RetryProvisioningOrchestrator implements ShouldQueue $service->save(); + ServiceProvisionLog::record($service, 'provision_completed', 'Khởi tạo lại hoàn tất — dịch vụ đã hoạt động', ['ip' => $moduleVmware->ip]); + if (!$moduleVmware->service_id) { $moduleVmware->update(['service_id' => $service->id]); } diff --git a/app/Jobs/VirtualMachine/Provision/SendVmCredentialsEmailJob.php b/app/Jobs/VirtualMachine/Provision/SendVmCredentialsEmailJob.php index d1bf97b..e678005 100644 --- a/app/Jobs/VirtualMachine/Provision/SendVmCredentialsEmailJob.php +++ b/app/Jobs/VirtualMachine/Provision/SendVmCredentialsEmailJob.php @@ -19,6 +19,7 @@ use Illuminate\Queue\InteractsWithQueue; use Illuminate\Queue\SerializesModels; use Illuminate\Support\Facades\DB; use Illuminate\Support\Facades\Log; +use App\Support\ServiceProvisionLog; /** * SendVmCredentialsEmailJob @@ -154,6 +155,8 @@ class SendVmCredentialsEmailJob implements ShouldQueue 'service_id' => $service->id, ]); + ServiceProvisionLog::record($service, 'provision_completed', 'Khởi tạo hoàn tất — dịch vụ đã hoạt động', ['ip' => $vm->ip]); + Log::info('SendVmCredentialsEmailJob: service activated', [ 'module_vmware_id' => $vm->id, 'service_id' => $service->id, diff --git a/app/Jobs/VirtualMachine/Provision/UpdateVmConfigJob.php b/app/Jobs/VirtualMachine/Provision/UpdateVmConfigJob.php index 33c2121..7bde68a 100644 --- a/app/Jobs/VirtualMachine/Provision/UpdateVmConfigJob.php +++ b/app/Jobs/VirtualMachine/Provision/UpdateVmConfigJob.php @@ -22,6 +22,7 @@ use Illuminate\Queue\InteractsWithQueue; use Illuminate\Queue\SerializesModels; use App\Support\TlsHttpClientFactory; use Illuminate\Support\Facades\Log; +use App\Support\ServiceProvisionLog; /** * UpdateVmConfigJob @@ -208,6 +209,10 @@ class UpdateVmConfigJob implements ShouldQueue $moduleVmware->update(['cpu' => $totalCpu, 'ram_mb' => $totalRam * 1024]); + if (! $isManualOverride) { + ServiceProvisionLog::forVm($this->moduleVmwareId, 'provision_step', "Đã cấu hình CPU/RAM ({$totalCpu} vCPU, {$totalRam} GB)"); + } + if ($isManualOverride) { $this->syncServiceAddons($moduleVmware, $totalCpu, $totalRam, $logger); return; @@ -227,6 +232,7 @@ class UpdateVmConfigJob implements ShouldQueue ModuleVmware::where('id', $this->moduleVmwareId)->update(['status' => 0]); Service::where('vm_id', $this->moduleVmwareId)->update(['status' => 'failed']); + ServiceProvisionLog::forVm($this->moduleVmwareId, 'provision_failed', 'Cấu hình CPU/RAM thất bại — ' . $e->getMessage(), ['stage' => 'update_config']); try { $telegram = app(Telegram::class); diff --git a/app/Models/Mail.php b/app/Models/Mail.php index 791fd40..e4f4d1f 100644 --- a/app/Models/Mail.php +++ b/app/Models/Mail.php @@ -21,6 +21,7 @@ class Mail extends Model 'bcc', 'customer_id', 'order_id', + 'service_id', 'sent_by', 'message', 'status' diff --git a/app/Models/MailCampaign.php b/app/Models/MailCampaign.php index fcf6d00..6738662 100644 --- a/app/Models/MailCampaign.php +++ b/app/Models/MailCampaign.php @@ -26,6 +26,7 @@ class MailCampaign extends Model 'message', 'variables', 'attachments', + 'renewal_services', 'cc', 'bcc', 'recipient_type', @@ -40,6 +41,7 @@ class MailCampaign extends Model protected $casts = [ 'variables' => 'array', 'attachments' => 'array', + 'renewal_services' => 'array', 'total_recipients' => 'integer', 'sent_count' => 'integer', 'failed_count' => 'integer', diff --git a/app/Models/ServicePlan.php b/app/Models/ServicePlan.php index f4bade9..061bbf5 100644 --- a/app/Models/ServicePlan.php +++ b/app/Models/ServicePlan.php @@ -41,6 +41,21 @@ class ServicePlan extends Model return $this->belongsTo(DnsServer::class, 'dns_manager_server_id'); } + public function servers() + { + return $this->belongsToMany(DnsServer::class, 'service_plan_servers', 'service_plan_id', 'dns_manager_server_id') + ->withPivot('priority') + ->orderByPivot('priority'); + } + + public function syncServers(array $serverIds): void + { + $ids = array_values(array_unique(array_map('intval', $serverIds))); + + $this->servers()->sync(collect($ids)->mapWithKeys(fn (int $id, int $index) => [$id => ['priority' => $index]])->all()); + $this->update(['dns_manager_server_id' => $ids[0] ?? null]); + } + public function summary(): array { return [ diff --git a/app/Models/Template.php b/app/Models/Template.php index d15ff72..5dba2e2 100644 --- a/app/Models/Template.php +++ b/app/Models/Template.php @@ -54,9 +54,18 @@ class Template extends Model } } + public function category(): string + { + $described = EmailScenarioRegistry::describe($this->key, $this->scenario); + + return $described['registered'] || ($this->metadata['marketing'] ?? true) !== false + ? $described['category'] + : EmailScenarioRegistry::BUSINESS; + } + public function isMarketing(): bool { - return EmailScenarioRegistry::describe($this->key, $this->scenario)['category'] === EmailScenarioRegistry::MARKETING; + return $this->category() === EmailScenarioRegistry::MARKETING; } /** Danh sách variable hợp lệ khai báo trong metadata.variables, key theo variable key. */ diff --git a/app/Services/Api/Client/ClientTwoFactorService.php b/app/Services/Api/Client/ClientTwoFactorService.php index abe8db9..15623ed 100644 --- a/app/Services/Api/Client/ClientTwoFactorService.php +++ b/app/Services/Api/Client/ClientTwoFactorService.php @@ -10,6 +10,7 @@ use App\Models\CustomerTrustedDevice; use App\Support\AutoEmailGate; use Illuminate\Support\Collection; use Illuminate\Support\Facades\Cache; +use Illuminate\Support\Facades\Crypt; use Illuminate\Support\Facades\Hash; use Illuminate\Support\Facades\Mail; use Illuminate\Support\Str; @@ -19,7 +20,9 @@ class ClientTwoFactorService { public const CODE_TTL_MINUTES = 5; - public const RESEND_COOLDOWN_SECONDS = 60; + public const RESEND_COOLDOWN_SECONDS = self::CODE_TTL_MINUTES * 60; + + private const ENTRY_TTL_MINUTES = 15; public const TRUSTED_DEVICE_DAYS = 90; @@ -37,17 +40,50 @@ class ClientTwoFactorService /** * @return array{challenge: string, email: string, expires_in: int, resend_in: int} */ - public function startLoginChallenge(Customer $customer): array + public function startLoginChallenge(Customer $customer, ?string $ip, ?string $userAgent): array { - $challenge = Str::random(64); - $this->issueCode($this->challengeKey($challenge), $customer, self::PURPOSE_LOGIN); + $pointerKey = $this->clientPointerKey($customer, $ip, $userAgent); - return [ - 'challenge' => $challenge, - 'email' => $this->maskEmail($customer->email), - 'expires_in' => self::CODE_TTL_MINUTES * 60, - 'resend_in' => self::RESEND_COOLDOWN_SECONDS, - ]; + return Cache::lock($pointerKey . ':lock', 10)->block(5, function () use ($customer, $pointerKey) { + $challenge = $this->activeChallenge($pointerKey); + + if (!$challenge) { + $challenge = Str::random(64); + $this->issueCode($this->challengeKey($challenge), $customer, self::PURPOSE_LOGIN); + Cache::put($pointerKey, Crypt::encryptString($challenge), now()->addMinutes(self::ENTRY_TTL_MINUTES)); + } + + $remaining = max(Cache::get($this->challengeKey($challenge))['expires_at'] - now()->getTimestamp(), 0); + + return [ + 'challenge' => $challenge, + 'email' => $this->maskEmail($customer->email), + 'expires_in' => $remaining, + 'resend_in' => $remaining, + ]; + }); + } + + private function activeChallenge(string $pointerKey): ?string + { + $stored = Cache::get($pointerKey); + $challenge = $stored ? Crypt::decryptString($stored) : null; + $entry = $challenge ? Cache::get($this->challengeKey($challenge)) : null; + + if ($entry && $entry['expires_at'] > now()->getTimestamp()) { + return $challenge; + } + + if ($challenge) { + Cache::forget($this->challengeKey($challenge)); + } + + return null; + } + + private function clientPointerKey(Customer $customer, ?string $ip, ?string $userAgent): string + { + return 'client-2fa:client:' . hash('sha256', $customer->id . '|' . $ip . '|' . $userAgent); } public function resendLoginChallenge(string $challenge): void @@ -186,7 +222,7 @@ class ClientTwoFactorService 'attempts' => 0, 'sent_at' => now()->getTimestamp(), 'expires_at' => now()->addMinutes(self::CODE_TTL_MINUTES)->getTimestamp(), - ], now()->addMinutes(self::CODE_TTL_MINUTES)); + ], now()->addMinutes(self::ENTRY_TTL_MINUTES)); AutoEmailGate::markManual(); Mail::to($customer->email)->queue(new TwoFactorCode([ @@ -199,6 +235,12 @@ class ClientTwoFactorService private function checkCode(string $key, array $entry, string $code): void { + if ($entry['expires_at'] <= now()->getTimestamp()) { + throw ValidationException::withMessages([ + 'code' => ['Mã xác thực đã hết hạn, vui lòng gửi lại mã.'], + ]); + } + if (hash_equals($entry['code_hash'], hash('sha256', $key . $code))) { Cache::forget($key); @@ -214,7 +256,7 @@ class ClientTwoFactorService ]); } - Cache::put($key, $entry, max($entry['expires_at'] - now()->getTimestamp(), 1)); + Cache::put($key, $entry, now()->addMinutes(self::ENTRY_TTL_MINUTES)); throw ValidationException::withMessages([ 'code' => ['Mã xác thực không chính xác.'], diff --git a/app/Services/Api/Order/OrderService.php b/app/Services/Api/Order/OrderService.php index 6778565..70988b3 100644 --- a/app/Services/Api/Order/OrderService.php +++ b/app/Services/Api/Order/OrderService.php @@ -14,6 +14,7 @@ use App\Repositories\Api\Service\ServiceRepository; use App\Services\InvoicePaymentService; use App\Services\AdminPaymentNotifier; use App\Services\Coupon\CouponService; +use App\Services\Hosting\HostingServerSelector; use App\Services\VirtualMachine\VmIpReservationService; use App\Support\AddonQuantityValidator; use Illuminate\Support\Facades\DB; @@ -28,6 +29,7 @@ class OrderService protected AdminPaymentNotifier $adminPaymentNotifier, protected CouponService $couponService, protected VmIpReservationService $vmIpReservation, + protected HostingServerSelector $hostingServers, ) {} // ========================================================================= @@ -116,6 +118,21 @@ class OrderService if ($needsVm && !$this->vmIpReservation->hasFreeIp()) { throw new \InvalidArgumentException('Hiện tại hạ tầng đang đầy vui lòng liên hệ quản trị viên'); } + + $this->assertHostingServerAvailable($products); + } + + private function assertHostingServerAvailable(iterable $products): void + { + foreach ($products as $product) { + if ($product?->product_type !== 'hosting' || ! $product->servicePlan) { + continue; + } + + if (! $this->hostingServers->hasHealthyServer($product->servicePlan)) { + throw new \InvalidArgumentException('Dịch vụ hosting đang bảo trì, vui lòng thử lại sau hoặc liên hệ quản trị viên.'); + } + } } /** diff --git a/app/Services/Concerns/ActivatesPaidInvoices.php b/app/Services/Concerns/ActivatesPaidInvoices.php index edab515..52ee12b 100644 --- a/app/Services/Concerns/ActivatesPaidInvoices.php +++ b/app/Services/Concerns/ActivatesPaidInvoices.php @@ -20,6 +20,7 @@ use Carbon\Carbon; use Carbon\CarbonInterface; use Illuminate\Support\Facades\DB; use Illuminate\Support\Facades\Log; +use App\Support\ServiceProvisionLog; trait ActivatesPaidInvoices { @@ -59,6 +60,13 @@ trait ActivatesPaidInvoices $this->attachServiceAddons($service, $item); + if ($service->wasRecentlyCreated && $service->status === 'pending') { + ServiceProvisionLog::record($service, 'provision_requested', 'Khách hàng yêu cầu khởi tạo dịch vụ', [ + 'order_num' => $order->order_num, + 'invoice_number' => $invoice->invoice_number, + ]); + } + if ($service->wasRecentlyCreated && $item->product_id) { if ($item->os_id) { dispatch(new CreateVirtualMachineJob($item)); diff --git a/app/Services/Email/MailCampaignRenderer.php b/app/Services/Email/MailCampaignRenderer.php index cefe906..eb02740 100644 --- a/app/Services/Email/MailCampaignRenderer.php +++ b/app/Services/Email/MailCampaignRenderer.php @@ -7,6 +7,7 @@ use App\Models\MailCampaign; use App\Models\Template; use App\Support\MailGlobals; use App\Support\MailPlaceholders; +use App\Support\RenewalCampaign; final class MailCampaignRenderer { @@ -43,7 +44,7 @@ final class MailCampaignRenderer array_keys(MailPlaceholders::RECIPIENT_FIELDS), )); - return [...MailGlobals::values(), ...$samples, ...$this->adminValues($campaign)]; + return [...MailGlobals::values(), ...$samples, ...$this->adminValues($campaign), ...RenewalCampaign::sampleValues($campaign)]; } private function adminValues(MailCampaign $campaign): array diff --git a/app/Services/Hosting/HostingProvisionFailure.php b/app/Services/Hosting/HostingProvisionFailure.php new file mode 100644 index 0000000..a0c63c6 --- /dev/null +++ b/app/Services/Hosting/HostingProvisionFailure.php @@ -0,0 +1,100 @@ +error('ProvisionHostingAccountJob failed', [ + 'order_item_id' => $item->id, + 'reason' => $reason, + ]); + + $service = Service::where('order_item_id', $item->id)->first(); + + Service::where('order_item_id', $item->id) + ->whereIn('status', ['pending', 'provisioning']) + ->update(['status' => 'failed']); + + if ($service) { + $this->activity->log('provision_failed', 'service', 'Khởi tạo hosting thất bại — ' . $reason, $service); + } + + $refunded = $this->refund->refund($item, $reason); + + $this->notifyAdmin($item, $reason, $refunded); + $this->notifyCustomer($item); + } + + public function serverFailedOver(OrderItem $item, DnsServer $server, string $error): void + { + Log::channel('Custom')->warning('ProvisionHostingAccountJob server failed over', [ + 'order_item_id' => $item->id, + 'server_id' => $server->id, + 'error' => $error, + ]); + + $this->telegram->SendErrorMessage([ + 'job' => 'ProvisionHostingAccountJob', + 'title' => 'Server Plesk lỗi, chuyển sang server dự phòng', + 'order' => $item->order?->order_num, + 'domain' => $item->server_name, + 'server' => "{$server->name} ({$server->ip_address})", + 'error' => $error, + ]); + } + + private function notifyAdmin(OrderItem $item, string $reason, bool $refunded): void + { + $order = $item->order; + + $this->telegram->SendErrorMessage([ + 'job' => 'ProvisionHostingAccountJob', + 'title' => 'Khởi tạo hosting thất bại', + 'order' => $order?->order_num, + 'domain' => $item->server_name, + 'customer' => $order?->customer?->email, + 'error' => $reason, + 'refunded' => $refunded ? 'đã hoàn tiền tự động' : 'chưa hoàn tiền, cần xử lý thủ công', + ]); + } + + private function notifyCustomer(OrderItem $item): void + { + try { + $order = $item->order; + $customer = $order->customer; + + VMCreationFailedJob::dispatch([ + 'customer_email' => $customer->email, + 'customer_name' => (string) $customer->fullname, + 'product_name' => $item->getProductAttribute()?->name ?? 'Hosting', + 'order_id' => $order->id, + 'order_num' => $order->order_num, + 'order_item_id' => $item->id, + ]); + } catch (Throwable $e) { + Log::error('HostingProvisionFailure: cannot dispatch failure email: ' . $e->getMessage()); + } + } +} diff --git a/app/Services/Hosting/HostingServerSelector.php b/app/Services/Hosting/HostingServerSelector.php new file mode 100644 index 0000000..c827ee3 --- /dev/null +++ b/app/Services/Hosting/HostingServerSelector.php @@ -0,0 +1,69 @@ +servers()->get() + ->filter(fn (DnsServer $server) => $server->status === 'activated' && $server->servesHosting()) + ->values(); + } + + public function pick(ServicePlan $plan, array $excludeIds = []): ?DnsServer + { + return $this->candidates($plan)->first( + fn (DnsServer $server) => ! in_array($server->id, $excludeIds, true) && $this->isHealthy($server) + ); + } + + public function hasHealthyServer(ServicePlan $plan): bool + { + return $this->pick($plan) !== null; + } + + public function isHealthy(DnsServer $server): bool + { + return Cache::remember($this->cacheKey($server), self::HEALTH_TTL_SECONDS, fn () => $this->probe($server)); + } + + public function forget(DnsServer $server): void + { + Cache::forget($this->cacheKey($server)); + } + + private function probe(DnsServer $server): bool + { + try { + return $this->plesk + ->client($server, self::PROBE_TIMEOUT_SECONDS) + ->get($this->plesk->url($server, 'server')) + ->successful(); + } catch (Throwable) { + return false; + } + } + + private function cacheKey(DnsServer $server): string + { + return 'hosting-server-health:' . $server->id; + } +} diff --git a/app/Services/Plesk/PleskService.php b/app/Services/Plesk/PleskService.php index 7d01c96..ab6bbd5 100644 --- a/app/Services/Plesk/PleskService.php +++ b/app/Services/Plesk/PleskService.php @@ -14,6 +14,7 @@ class PleskService 'clients' => ':8443/api/v2/clients', 'domains' => ':8443/api/v2/domains', 'cli' => ':8443/api/v2/cli', + 'server' => ':8443/api/v2/server', ]; public function client(DnsServer $server, int $timeout = 30): PendingRequest diff --git a/app/Support/CustomerIdentity.php b/app/Support/CustomerIdentity.php new file mode 100644 index 0000000..0732872 --- /dev/null +++ b/app/Support/CustomerIdentity.php @@ -0,0 +1,57 @@ + 'front_identity_number', 'back' => 'back_identity_number']; + + public static function payload(Customer $customer): array + { + return [ + 'identity_number' => $customer->identity_number, + 'front_url' => self::signedUrl($customer, 'front'), + 'back_url' => self::signedUrl($customer, 'back'), + ]; + } + + public static function response(Customer $customer, string $side) + { + $disk = self::diskFor($customer->{self::SIDES[$side]}); + abort_unless($disk, 404); + + return Storage::disk($disk)->response($customer->{self::SIDES[$side]}, null, ['Cache-Control' => 'private, no-store']); + } + + private static function signedUrl(Customer $customer, string $side): ?string + { + if (! self::diskFor($customer->{self::SIDES[$side]})) { + return null; + } + + return URL::temporarySignedRoute( + 'admin.customers.identity-image', + now()->addHour(), + ['customer' => $customer->uuid, 'side' => $side], + ); + } + + private static function diskFor(?string $path): ?string + { + if (! is_string($path) || $path === '' || str_contains($path, '..')) { + return null; + } + + foreach (['local', 'public'] as $disk) { + if (Storage::disk($disk)->exists($path)) { + return $disk; + } + } + + return null; + } +} diff --git a/app/Support/RenewalCampaign.php b/app/Support/RenewalCampaign.php new file mode 100644 index 0000000..ef8bb85 --- /dev/null +++ b/app/Support/RenewalCampaign.php @@ -0,0 +1,62 @@ + + */ + public static function sampleValues(MailCampaign $campaign): array + { + $first = $campaign->renewal_services[0]['service_id'] ?? null; + $service = $first ? Service::with('customer')->find($first) : null; + + return $service?->customer ? (self::valuesFor($campaign, $service->customer, $service->id) ?? []) : []; + } + + public static function isRenewalTemplate(MailCampaign $campaign): bool + { + $keys = MailPlaceholders::extract($campaign->subject, $campaign->message); + + return in_array('period_end', $keys, true) && in_array('bank_qr_url', $keys, true); + } + + /** + * @return array|null null khi dịch vụ không còn thuộc khách hoặc không thể gia hạn + */ + public static function valuesFor(MailCampaign $campaign, Customer $customer, int $serviceId): ?array + { + $months = collect($campaign->renewal_services)->firstWhere('service_id', $serviceId)['months'] ?? null; + $service = Service::with(['orderItem', 'product', 'customProduct']) + ->whereKey($serviceId) + ->where('customer_id', $customer->id) + ->first(); + $quote = $service ? RenewalQuote::forService($service, $months) : null; + if (! $quote) { + return null; + } + + unset($quote['service_id'], $quote['cycles'], $quote['months']); + + $bank = Bank::where('account_no', $campaign->variables['bank_account_no'] ?? '')->first(); + + return $bank + ? [...$quote, 'bank_qr_url' => VietQr::url($bank, $quote['total_amount'], $quote['payment_content'])] + : $quote; + } +} diff --git a/app/Support/RenewalQuote.php b/app/Support/RenewalQuote.php new file mode 100644 index 0000000..c50cac7 --- /dev/null +++ b/app/Support/RenewalQuote.php @@ -0,0 +1,86 @@ + 'tên miền', + 'hosting' => 'hosting', + 'vps' => 'Cloud VPS', + 'ssl' => 'SSL', + ]; + + /** + * @param array $customerIds + * @return Collection + */ + public static function dueServices(array $customerIds, int $days): Collection + { + return Service::query() + ->with(['customer:id,uuid,fullname,email', 'orderItem', 'product', 'customProduct']) + ->whereIn('customer_id', $customerIds) + ->whereNotIn('status', ['terminated', 'cancelled']) + ->whereNotNull('expires_at') + ->where('expires_at', '<=', now()->addDays($days)->endOfDay()) + ->orderBy('expires_at') + ->get(); + } + + public static function forService(Service $service, ?int $months = null): ?array + { + $orderItem = $service->orderItem; + $product = $service->customProduct ?? $service->product; + if (! $orderItem || ! $product) { + return null; + } + + $baseMonths = OrderHelper::cycleToMonths($orderItem->billing_cycle); + $options = RenewalPricing::options($product); + $base = RenewalPricing::option($product, $baseMonths); + if (! $base) { + return null; + } + + $rate = VatSettings::rateFor($service->product?->product_type); + $start = ($service->expires_at ?? now())->copy(); + $target = $service->server_name ?: $product->name; + $label = self::TYPE_LABELS[$service->type] ?? null; + $terms = fn (array $option) => self::terms($service, $option, $baseMonths, $start, $rate); + + return [ + 'service_id' => $service->id, + 'service_name' => 'Gia hạn ' . ($label ? $label . ' - ' : '') . $target, + 'period_start' => $start->format('d/m/Y'), + 'expires_date' => $start->format('d/m/Y'), + 'vat_rate' => rtrim(rtrim(number_format($rate, 2, '.', ''), '0'), '.'), + 'payment_content' => Str::limit('GIA HAN ' . Str::upper(Str::ascii($target)), 50, ''), + ...$terms(RenewalPricing::option($product, (int) $months) ?? $base), + 'cycles' => array_map($terms, $options), + ]; + } + + /** + * @param array{months: int, price: int} $option + * @return array{months: int, billing_cycle: string, period_end: string, total_amount: string, vat_amount: string} + */ + private static function terms(Service $service, array $option, int $baseMonths, Carbon $start, float $rate): array + { + $months = $option['months']; + $amount = $option['price'] + RenewalPricing::addonTotal($service, $months / $baseMonths); + + return [ + 'months' => $months, + 'billing_cycle' => sprintf('%02d %s', $months % 12 === 0 ? $months / 12 : $months, $months % 12 === 0 ? 'năm' : 'tháng'), + 'period_end' => $start->copy()->addMonthsNoOverflow($months)->subDay()->format('d/m/Y'), + 'total_amount' => number_format($amount, 0, ',', '.'), + 'vat_amount' => number_format((int) round($amount * $rate / (100 + $rate)), 0, ',', '.'), + ]; + } +} diff --git a/app/Support/ServiceProvisionLog.php b/app/Support/ServiceProvisionLog.php new file mode 100644 index 0000000..c1d9ab2 --- /dev/null +++ b/app/Support/ServiceProvisionLog.php @@ -0,0 +1,36 @@ +log($event, 'service', $description, $service, [], $values); + } + + public static function forOrderItem(int $orderItemId, string $event, string $description, array $values = []): void + { + self::record(Service::where('order_item_id', $orderItemId)->first(), $event, $description, $values); + } + + public static function forVm(int $moduleVmwareId, string $event, string $description, array $values = []): void + { + self::record(Service::where('vm_id', $moduleVmwareId)->first(), $event, $description, $values); + } +} diff --git a/app/Support/VietQr.php b/app/Support/VietQr.php new file mode 100644 index 0000000..b6a361d --- /dev/null +++ b/app/Support/VietQr.php @@ -0,0 +1,19 @@ + preg_replace('/\D/', '', $amount), + 'addInfo' => trim($content), + 'accountName' => $bank->account_name, + ], fn (?string $value) => $value !== null && $value !== ''); + + return "https://img.vietqr.io/image/{$bank->bank_code}-{$bank->account_no}-compact2.png?" . http_build_query($params, '', '&', PHP_QUERY_RFC3986); + } +} diff --git a/database/migrations/2026_10_30_000001_create_service_plan_servers_table.php b/database/migrations/2026_10_30_000001_create_service_plan_servers_table.php new file mode 100644 index 0000000..dcd5a20 --- /dev/null +++ b/database/migrations/2026_10_30_000001_create_service_plan_servers_table.php @@ -0,0 +1,38 @@ +id(); + $table->foreignId('service_plan_id')->constrained('service_plans')->cascadeOnDelete(); + $table->foreignId('dns_manager_server_id')->constrained('dns_manager_servers')->cascadeOnDelete(); + $table->unsignedSmallInteger('priority')->default(0); + $table->timestamps(); + + $table->unique(['service_plan_id', 'dns_manager_server_id'], 'plan_server_unique'); + }); + + DB::table('service_plans') + ->whereNotNull('dns_manager_server_id') + ->get(['id', 'dns_manager_server_id']) + ->each(fn ($plan) => DB::table('service_plan_servers')->insert([ + 'service_plan_id' => $plan->id, + 'dns_manager_server_id' => $plan->dns_manager_server_id, + 'priority' => 0, + 'created_at' => now(), + 'updated_at' => now(), + ])); + } + + public function down(): void + { + Schema::dropIfExists('service_plan_servers'); + } +}; diff --git a/database/migrations/2026_10_30_000005_seed_renewal_reminder_qr_template.php b/database/migrations/2026_10_30_000005_seed_renewal_reminder_qr_template.php new file mode 100644 index 0000000..e7babe4 --- /dev/null +++ b/database/migrations/2026_10_30_000005_seed_renewal_reminder_qr_template.php @@ -0,0 +1,18 @@ +seedMarketingTemplates(); + } + + public function down(): void + { + DB::table('template')->where('name', 'Nhắc gia hạn dịch vụ - Thanh toán QR')->delete(); + } +}; diff --git a/database/migrations/2026_10_30_000006_add_renewal_services_to_mail_campaigns_table.php b/database/migrations/2026_10_30_000006_add_renewal_services_to_mail_campaigns_table.php new file mode 100644 index 0000000..759e937 --- /dev/null +++ b/database/migrations/2026_10_30_000006_add_renewal_services_to_mail_campaigns_table.php @@ -0,0 +1,22 @@ +json('renewal_services')->nullable()->after('attachments'); + }); + } + + public function down(): void + { + Schema::table('mail_campaigns', function (Blueprint $table) { + $table->dropColumn('renewal_services'); + }); + } +}; diff --git a/database/migrations/2026_10_30_000007_add_service_id_to_mail_table.php b/database/migrations/2026_10_30_000007_add_service_id_to_mail_table.php new file mode 100644 index 0000000..a2bb19f --- /dev/null +++ b/database/migrations/2026_10_30_000007_add_service_id_to_mail_table.php @@ -0,0 +1,22 @@ +unsignedBigInteger('service_id')->nullable()->after('order_id'); + }); + } + + public function down(): void + { + Schema::table('mail', function (Blueprint $table) { + $table->dropColumn('service_id'); + }); + } +}; diff --git a/database/migrations/2026_10_30_000008_mark_service_templates_as_non_marketing.php b/database/migrations/2026_10_30_000008_mark_service_templates_as_non_marketing.php new file mode 100644 index 0000000..2966e4f --- /dev/null +++ b/database/migrations/2026_10_30_000008_mark_service_templates_as_non_marketing.php @@ -0,0 +1,16 @@ +markNonMarketing(); + } + + public function down(): void + { + } +}; diff --git a/database/seeders/EmailTemplates/MarketingTemplates.php b/database/seeders/EmailTemplates/MarketingTemplates.php index 8aaa846..18c58c2 100644 --- a/database/seeders/EmailTemplates/MarketingTemplates.php +++ b/database/seeders/EmailTemplates/MarketingTemplates.php @@ -12,6 +12,7 @@ final class MarketingTemplates public static function all(): array { return [ + ...RenewalReminderTemplates::all(), [ 'name' => 'Khuyến mãi tháng 10', 'subject' => 'Ưu đãi 30% Cloud VPS dành riêng cho {{client_name}}', @@ -24,6 +25,7 @@ final class MarketingTemplates ], [ 'name' => 'Thông báo bảo trì hệ thống', + 'marketing' => false, 'subject' => 'V/v: Thông báo bảo trì hệ thống ngày {{maintenance_date}}', 'body' => T::title('THÔNG BÁO BẢO TRÌ') . T::greeting('{{client_name}}') @@ -42,6 +44,7 @@ final class MarketingTemplates ], [ 'name' => 'Thông báo bổ sung hồ sơ bản khai tên miền', + 'marketing' => false, 'subject' => 'V/v: Thế Giới Số thông báo bổ sung hồ sơ bản khai tên miền của Quý Công ty - {{customer_name}}', 'body' => self::domainProfileSupplementBody(), 'variables' => [ diff --git a/database/seeders/EmailTemplates/RenewalReminderTemplates.php b/database/seeders/EmailTemplates/RenewalReminderTemplates.php new file mode 100644 index 0000000..09d7d05 --- /dev/null +++ b/database/seeders/EmailTemplates/RenewalReminderTemplates.php @@ -0,0 +1,71 @@ +}> + */ + public static function all(): array + { + return [ + [ + 'name' => 'Nhắc gia hạn dịch vụ - Thanh toán QR', + 'marketing' => false, + 'subject' => 'V/v: Thế Giới Số nhắc gia hạn dịch vụ - {{service_name}} | {{customer_name}}', + 'body' => self::body(), + 'variables' => [ + ['key' => 'service_name', 'label' => 'Tên dịch vụ', 'example' => 'Gia hạn tên miền - thangtuong.com'], + ['key' => 'billing_cycle', 'label' => 'Chu kỳ', 'example' => '01 năm'], + ['key' => 'period_start', 'label' => 'Kỳ gia hạn từ ngày', 'example' => '18/11/2026'], + ['key' => 'period_end', 'label' => 'Kỳ gia hạn đến ngày', 'example' => '17/11/2027'], + ['key' => 'expires_date', 'label' => 'Ngày đến hạn', 'example' => '18/11/2026'], + ['key' => 'total_amount', 'label' => 'Số tiền thanh toán (đã VAT, VNĐ)', 'example' => '374.000'], + ['key' => 'vat_amount', 'label' => 'Tiền thuế VAT (VNĐ)', 'example' => '34.000'], + ['key' => 'vat_rate', 'label' => 'Thuế suất VAT (%)', 'example' => '10'], + ['key' => 'payment_content', 'label' => 'Nội dung chuyển khoản', 'example' => 'GIA HAN thangtuong.com'], + ['key' => 'bank_account_name', 'label' => 'Ngân hàng nhận thanh toán', 'example' => 'CÔNG TY CỔ PHẦN GIẢI PHÁP CÔNG NGHỆ THẾ GIỚI SỐ'], + ['key' => 'bank_account_no', 'label' => 'Số tài khoản', 'example' => '7379868'], + ['key' => 'bank_name', 'label' => 'Tên ngân hàng', 'example' => 'ACB – Chi nhánh TP. Hồ Chí Minh'], + ['key' => 'bank_qr_url', 'label' => 'Link ảnh VietQR', 'example' => 'https://img.vietqr.io/image/ACB-7379868-compact2.png?amount=374000'], + ], + ], + ]; + } + + private static function body(): string + { + $th = 'padding:8px 6px;color:#c2410c;font-size:10px;font-weight:bold;text-transform:uppercase;background:#fff5ee;text-align:center;white-space:nowrap;'; + $td = 'padding:14px 6px;font-size:13px;color:#1f2937;text-align:center;vertical-align:middle;'; + + return '

Kính gửi Quý Khách hàng {{customer_name}},

' + . T::paragraph('Thế Giới Số xin chân thành cảm ơn Quý khách hàng đã tin tưởng và sử dụng dịch vụ trong suốt thời gian qua.') + . '

Thế Giới Số xin gửi Quý khách chi tiết các dịch vụ đã đến hạn thanh toán như sau:

' + . '' + . '' + . '' + . '' + . '' + . '' + . '
Dịch vụChu kỳThành tiền (đã VAT)Đến hạn
{{service_name}}{{billing_cycle}}
{{period_start}}
đến {{period_end}}
{{total_amount}} đ
Gồm thuế VAT {{vat_rate}}%:
{{vat_amount}} đ
{{expires_date}}
' + . '
' + . '

TỔNG CỘNG CẦN THANH TOÁN

' + . '

{{total_amount}} đ

' + . '' + . '' + . '' + . '
' + . '

THÔNG TIN THANH TOÁN

' + . '

Chủ tài khoản: {{bank_account_name}}

' + . '

Số tài khoản: {{bank_account_no}}

' + . '

Ngân hàng: {{bank_name}}

' + . '

Nội dung: {{payment_content}}

VietQR' + . '

Quét mã để thanh toán nhanh

' + . '
' + . 'Nếu Quý Khách đã hoàn tất thanh toán, kính mong Quý Khách bỏ qua email này. Trường hợp thuận tiện, rất mong Quý khách phản hồi kèm Ủy nhiệm chi hoặc Chứng từ chuyển khoản để Thế Giới Số kiểm tra và xác nhận giúp Quý khách.' + . '
'; + } +} diff --git a/database/seeders/TemplateSeeder.php b/database/seeders/TemplateSeeder.php index fee3aa4..2eed3e4 100644 --- a/database/seeders/TemplateSeeder.php +++ b/database/seeders/TemplateSeeder.php @@ -25,19 +25,24 @@ class TemplateSeeder extends Seeder $this->upsert($item); } - foreach (MarketingTemplates::all() as $item) { - $this->createMarketingIfMissing($item); - } + $this->seedMarketingTemplates(); $this->seedServiceLifecycleTemplates(); - $this->createMarketingIfMissing(self::ipRevocationTemplate()); + $this->createMarketingIfMissing([...self::ipRevocationTemplate(), 'marketing' => false]); + } + + public function seedMarketingTemplates(): void + { + foreach (MarketingTemplates::all() as $item) { + $this->createMarketingIfMissing($item); + } } public function seedServiceLifecycleTemplates(): void { foreach (ServiceLifecycleTemplates::all() as $item) { - $this->createMarketingIfMissing($item); + $this->createMarketingIfMissing([...$item, 'marketing' => false]); } } @@ -77,10 +82,33 @@ class TemplateSeeder extends Seeder 'message' => $item['message'] ?? EmailTemplateShell::page($item['body']), 'status' => 1, ]); + if (($item['marketing'] ?? true) === false) { + $template->metadata = [...(array) $template->metadata, 'marketing' => false]; + } $template->syncVariables($item['variables']); $template->save(); } + public function markNonMarketing(): void + { + $items = [ + ...array_map(fn (array $item) => [...$item, 'marketing' => false], ServiceLifecycleTemplates::all()), + ...MarketingTemplates::all(), + [...self::ipRevocationTemplate(), 'marketing' => false], + ]; + + foreach ($items as $item) { + if (($item['marketing'] ?? true) !== false) { + continue; + } + + $template = Template::whereNull('key')->whereNull('scenario')->where('name', $item['name'])->first(); + if ($template && ! array_key_exists('marketing', (array) $template->metadata)) { + $template->forceFill(['metadata' => [...(array) $template->metadata, 'marketing' => false]])->save(); + } + } + } + /** * @return array}> */ diff --git a/routes/admin-media.php b/routes/admin-media.php index 0f3603c..e003f42 100644 --- a/routes/admin-media.php +++ b/routes/admin-media.php @@ -1,7 +1,9 @@ whereNumber('index') ->name('image'); }); + +Route::prefix('v1/administrator/customers') + ->as('admin.customers.') + ->middleware('signed') + ->group(function () { + Route::get('{customer:uuid}/identity-image/{side}', [CustomerIdentityController::class, 'image']) + ->whereIn('side', array_keys(CustomerIdentity::SIDES)) + ->name('identity-image'); + }); diff --git a/routes/api/admin/customers.php b/routes/api/admin/customers.php index 9340a18..f52adab 100644 --- a/routes/api/admin/customers.php +++ b/routes/api/admin/customers.php @@ -2,6 +2,7 @@ use App\Http\Controllers\Api\Admin\Customer\CustomerBillingProfileController; use App\Http\Controllers\Api\Admin\Customer\CustomerController; +use App\Http\Controllers\Api\Admin\Customer\CustomerIdentityController; use App\Http\Controllers\Api\Admin\Service\ServiceCancellationRequestController; use App\Http\Controllers\Api\Admin\Service\ServiceController; use App\Http\Controllers\Api\Admin\Service\ServiceRenewalReminderController; @@ -41,6 +42,7 @@ Route::prefix('customers') Route::post('{uuid}/services/{serviceId}/change-port', 'changeServicePort')->middleware('perm:customer.edit'); Route::post('{uuid}/services/{serviceId}/notify-renew', 'notifyServiceRenewal')->middleware('perm:customer.edit'); Route::get('{uuid}/services/{serviceId}/renewals', 'serviceRenewals')->middleware('perm:customer.view'); + Route::get('{uuid}/services/{serviceId}/provisioning-logs', 'serviceProvisioningLogs')->middleware('perm:customer.view'); Route::post('{uuid}/services/{serviceId}/renew', 'renewService')->middleware('perm:customer.edit'); Route::get('{uuid}/notes', 'getCustomerNotes')->middleware('perm:customer.view'); Route::post('{uuid}/notes', 'storeCustomerNote')->middleware('perm:customer.edit'); @@ -115,3 +117,5 @@ Route::prefix('customers/{uuid}/billing-profile')->controller(CustomerBillingPro Route::get('/', 'show')->middleware('perm:customer.view'); Route::put('/', 'update')->middleware('perm:customer.edit'); }); + +Route::get('customers/{uuid}/identity', [CustomerIdentityController::class, 'show'])->middleware('perm:customer.view'); diff --git a/routes/api/admin/system.php b/routes/api/admin/system.php index 0c8008f..d9eeebe 100644 --- a/routes/api/admin/system.php +++ b/routes/api/admin/system.php @@ -188,6 +188,7 @@ Route::prefix('mail-campaigns') Route::get('/', 'index')->middleware('perm:mail-campaign.view'); Route::get('variable-suggestions', 'variableSuggestions')->middleware('perm:mail-campaign.create'); Route::get('sender', 'sender')->middleware('perm:mail-campaign.create'); + Route::get('renewal-services', 'renewalServices')->middleware('perm:mail-campaign.create'); Route::post('preview', 'preview')->middleware('perm:mail-campaign.create'); Route::post('resolve-emails', 'resolveEmails')->middleware('perm:mail-campaign.create'); Route::post('bulk-action', 'bulkAction')->middleware('perm:mail-campaign.create'); diff --git a/routes/client.php b/routes/client.php index 639df3a..13c22c1 100644 --- a/routes/client.php +++ b/routes/client.php @@ -9,6 +9,7 @@ use App\Http\Controllers\Api\Client\Service\DomainController as ClientDomainCont use App\Http\Controllers\Api\Client\Service\ServiceController as ClientServiceController; use App\Http\Controllers\Api\Client\Service\SslArchiveController; use App\Http\Controllers\Api\Client\Service\ServiceRenewalHistoryController; +use App\Http\Controllers\Api\Client\Service\ServiceRenewalOptionsController; use App\Http\Controllers\Api\Client\Service\HostingPasswordController; use App\Http\Controllers\Api\Client\Service\ServiceCancellationController; use App\Http\Controllers\Api\Service\CouponPreviewController; @@ -133,6 +134,7 @@ Route::prefix('v1') Route::post('{uuid}/cancel-request', [ServiceCancellationController::class, 'store'])->middleware('throttle:5,1')->name('cancel-request.store'); Route::delete('{uuid}/cancel-request', [ServiceCancellationController::class, 'destroy'])->name('cancel-request.destroy'); Route::get('{uuid}/renewals', ServiceRenewalHistoryController::class)->name('renewals'); + Route::get('{uuid}/renewal-options', ServiceRenewalOptionsController::class)->name('renewal-options'); Route::get('{uuid}/ssl-archive', SslArchiveController::class)->name('ssl-archive'); Route::put('{uuid}/hosting-password', HostingPasswordController::class)->middleware('throttle:5,1')->name('hosting-password.update'); Route::get('{uuid}/console-ticket', 'getConsoleTicket')->name('console-ticket'); diff --git a/tests/Feature/Api/Admin/MailCampaignRenewalServicesTest.php b/tests/Feature/Api/Admin/MailCampaignRenewalServicesTest.php new file mode 100644 index 0000000..16d2107 --- /dev/null +++ b/tests/Feature/Api/Admin/MailCampaignRenewalServicesTest.php @@ -0,0 +1,64 @@ + 'role-' . uniqid(), 'guard_name' => 'admin']); + $role->givePermissionTo(Permission::firstOrCreate(['name' => 'mail-campaign.create', 'guard_name' => 'admin'])); + $admin = User::factory()->create(); + $admin->assignRole($role); + + return $admin; + } + + public function test_renewal_services_listing_only_contains_due_services_of_selected_customers(): void + { + $customer = Customer::factory()->create(); + + $this->actingAs($this->admin(), 'admin') + ->getJson('/api/v1/administrator/mail-campaigns/renewal-services?' . http_build_query(['uuids' => [$customer->uuid], 'days' => 30])) + ->assertOk() + ->assertExactJson(['error' => false, 'data' => []]); + } + + public function test_renewal_services_are_rejected_for_a_template_that_is_not_a_renewal_reminder(): void + { + Queue::fake(); + $customer = Customer::factory()->create(); + $template = Template::create([ + 'type_id' => 0, 'name' => 'T ' . uniqid(), 'format' => 'html', + 'subject' => 'Ưu đãi', 'message' => '

Hello

', 'status' => 1, + ]); + $service = Service::query()->forceCreate([ + 'uuid' => (string) \Illuminate\Support\Str::uuid(), 'customer_id' => $customer->id, + 'product_id' => Product::factory()->create()->id, 'type' => 'vps', 'status' => 'active', + ]); + + $this->actingAs($this->admin(), 'admin') + ->postJson('/api/v1/administrator/mail-campaigns', [ + 'template_id' => $template->id, + 'recipient_type' => 'selected', + 'uuids' => [$customer->uuid], + 'renewal_services' => [['service_id' => $service->id, 'months' => 1]], + ]) + ->assertStatus(422); + } +} diff --git a/tests/Feature/Api/Client/ClientTwoFactorTest.php b/tests/Feature/Api/Client/ClientTwoFactorTest.php index af71b98..e6fba3f 100644 --- a/tests/Feature/Api/Client/ClientTwoFactorTest.php +++ b/tests/Feature/Api/Client/ClientTwoFactorTest.php @@ -116,12 +116,33 @@ class ClientTwoFactorTest extends TestCase $this->assertTrue($device->expires_at->between(now()->addDays(89), now()->addDays(91))); $this->login($customer, $deviceToken)->assertOk()->assertJsonStructure(['data' => ['token']]); - $this->login($customer, str_repeat('x', 64))->assertOk()->assertJsonPath('data.two_factor.resend_in', 60); + $this->login($customer, str_repeat('x', 64))->assertOk()->assertJsonPath('data.two_factor.resend_in', 300); $this->travel(91)->days(); $this->login($customer, $deviceToken)->assertOk()->assertJsonStructure(['data' => ['two_factor']]); } + public function test_repeated_logins_from_same_client_reuse_code_until_it_expires(): void + { + Mail::fake(); + $customer = $this->customer(twoFactor: true); + + $first = $this->login($customer)->json('data.two_factor.challenge'); + $this->travel(2)->minutes(); + $second = $this->login($customer)->assertJsonPath('data.two_factor.resend_in', 180)->json('data.two_factor.challenge'); + + $this->assertSame($first, $second); + Mail::assertQueuedCount(1); + + $this->withServerVariables(['REMOTE_ADDR' => '203.0.113.9'])->login($customer); + Mail::assertQueuedCount(2); + + $this->travel(301)->seconds(); + $third = $this->login($customer)->json('data.two_factor.challenge'); + $this->assertNotSame($first, $third); + Mail::assertQueuedCount(3); + } + public function test_challenge_is_destroyed_after_five_wrong_codes(): void { Mail::fake(); diff --git a/tests/Feature/Jobs/HostingServerFailoverTest.php b/tests/Feature/Jobs/HostingServerFailoverTest.php new file mode 100644 index 0000000..b6d949b --- /dev/null +++ b/tests/Feature/Jobs/HostingServerFailoverTest.php @@ -0,0 +1,173 @@ + "plesk-{$ip}", + 'ip_address' => $ip, + 'username' => 'admin', + 'password' => Crypt::encryptString('secret'), + 'status' => 'activated', + 'type' => 'hosting', + ]); + } + + private function plan(DnsServer ...$servers): ServicePlan + { + $plan = ServicePlan::create(['title' => 'Basic', 'vcpu' => 1, 'ram' => 1, 'disk' => 10, 'domain' => 1]); + $plan->plesk()->create(['plesk_plan_name' => 'basic-hosting']); + $plan->syncServers(array_map(fn (DnsServer $server) => $server->id, $servers)); + + return $plan->fresh(); + } + + public function test_pick_skips_unhealthy_and_inactive_servers_in_priority_order(): void + { + Cache::flush(); + $down = $this->server('10.0.0.1'); + $inactive = $this->server('10.0.0.2', ['status' => 'deactivated']); + $up = $this->server('10.0.0.3'); + $plan = $this->plan($down, $inactive, $up); + + Http::fake([ + '10.0.0.1:8443/*' => Http::response([], 500), + '*' => Http::response(['platform' => 'Linux'], 200), + ]); + + $selector = app(HostingServerSelector::class); + + $this->assertSame($up->id, $selector->pick($plan)->id); + $this->assertNull($selector->pick($plan, [$up->id])); + } + + public function test_plan_primary_server_column_follows_first_priority(): void + { + $a = $this->server('10.0.0.1'); + $b = $this->server('10.0.0.2'); + $plan = $this->plan($b, $a); + + $this->assertSame($b->id, (int) $plan->dns_manager_server_id); + $this->assertSame([$b->id, $a->id], $plan->servers->pluck('id')->all()); + } + + public function test_provisioning_fails_over_to_the_backup_server_when_the_primary_goes_down(): void + { + Cache::flush(); + Bus::fake([HostingAccountCreatedJob::class, HostingAccountCreatedAdminNotificationJob::class]); + $primary = $this->server('10.0.0.1'); + $backup = $this->server('10.0.0.2'); + $plan = $this->plan($primary, $backup); + + Http::fake([ + '10.0.0.1:8443/api/v2/server*' => Http::sequence()->push([], 200)->push([], 500), + '10.0.0.1:8443/*' => Http::response(['message' => 'down'], 500), + '10.0.0.2:8443/api/v2/server*' => Http::response([], 200), + '10.0.0.2:8443/api/v2/clients*' => Http::response(['id' => 7, 'guid' => 'client-guid'], 201), + '10.0.0.2:8443/*' => Http::response(['id' => 9, 'guid' => 'sub-guid', 'www_root' => '/var/www/vhosts/khach.test/httpdocs'], 201), + '*' => Http::response([], 200), + ]); + + [$item, $service] = $this->paidHostingOrder($plan); + + dispatch_sync(new ProvisionHostingAccountJob($item)); + + $this->assertSame('active', $service->fresh()->status); + $this->assertSame($backup->id, ModuleHostingAccount::where('service_id', $service->id)->value('dns_manager_server_id')); + } + + public function test_provisioning_fails_and_refunds_when_every_server_is_down(): void + { + Cache::flush(); + Bus::fake([VMCreationFailedJob::class]); + $plan = $this->plan($this->server('10.0.0.1'), $this->server('10.0.0.2')); + + Http::fake(['10.0.0.*' => Http::response([], 500), '*' => Http::response([], 200)]); + + [$item, $service, $customer, $invoice, $order] = $this->paidHostingOrder($plan); + + dispatch_sync(new ProvisionHostingAccountJob($item)); + + $this->assertSame('failed', $service->fresh()->status); + $this->assertSame(500_000, (int) $customer->fresh()->total); + $this->assertSame(InvoiceStatus::REFUNDED->value, (int) $invoice->fresh()->status); + $this->assertSame(OrderStatus::CANCELLED->value, $order->fresh()->status); + } + + private function paidHostingOrder(ServicePlan $plan): array + { + $customer = Customer::factory()->create(['total' => 500_000]); + $product = Product::factory()->create(['product_type' => 'hosting', 'service_plan_id' => $plan->id]); + $order = Order::create([ + 'order_num' => 'ORD-' . uniqid(), + 'customer_id' => $customer->id, + 'sub_total' => 200_000, + 'total' => 200_000, + 'status' => OrderStatus::PENDING->value, + ]); + $item = OrderItem::create([ + 'order_id' => $order->id, + 'product_id' => $product->id, + 'billing_cycle' => 'monthly', + 'months' => 1, + 'server_name' => 'khach.test', + 'quantity' => 1, + 'price' => 200_000, + ]); + $invoice = Invoice::create([ + 'invoice_number' => 'INV-' . uniqid(), + 'customer_id' => $customer->id, + 'order_id' => $order->id, + 'invoice_type' => 1, + 'sub_total' => 200_000, + 'tax' => 0, + 'total' => 200_000, + 'status' => InvoiceStatus::UNPAID->value, + ]); + $service = Service::create([ + 'customer_id' => $customer->id, + 'product_id' => $product->id, + 'order_item_id' => $item->id, + 'invoice_id' => $invoice->id, + 'server_name' => 'khach.test', + 'status' => 'pending', + 'started_at' => now(), + 'expires_at' => now()->addMonth(), + ]); + + app(InvoicePaymentService::class)->payWithBalance($invoice, $customer, 'balance'); + + return [$item->fresh(), $service, $customer, $invoice, $order]; + } +} diff --git a/tests/Feature/Jobs/ProvisionHostingAccountJobTest.php b/tests/Feature/Jobs/ProvisionHostingAccountJobTest.php new file mode 100644 index 0000000..e4dff27 --- /dev/null +++ b/tests/Feature/Jobs/ProvisionHostingAccountJobTest.php @@ -0,0 +1,82 @@ +create(['total' => 500_000]); + $product = Product::factory()->create(['product_type' => 'hosting', 'service_plan_id' => null]); + $order = Order::create([ + 'order_num' => 'ORD-' . uniqid(), + 'customer_id' => $customer->id, + 'sub_total' => 200_000, + 'total' => 200_000, + 'status' => OrderStatus::PENDING->value, + ]); + $item = OrderItem::create([ + 'order_id' => $order->id, + 'product_id' => $product->id, + 'billing_cycle' => 'monthly', + 'months' => 1, + 'server_name' => 'khach-hang.test', + 'quantity' => 1, + 'price' => 200_000, + ]); + $invoice = Invoice::create([ + 'invoice_number' => 'INV-' . uniqid(), + 'customer_id' => $customer->id, + 'order_id' => $order->id, + 'invoice_type' => 1, + 'sub_total' => 200_000, + 'tax' => 0, + 'total' => 200_000, + 'status' => InvoiceStatus::UNPAID->value, + ]); + $service = Service::create([ + 'customer_id' => $customer->id, + 'product_id' => $product->id, + 'order_item_id' => $item->id, + 'invoice_id' => $invoice->id, + 'server_name' => 'khach-hang.test', + 'status' => 'pending', + 'started_at' => now(), + 'expires_at' => now()->addMonth(), + ]); + + app(InvoicePaymentService::class)->payWithBalance($invoice, $customer, 'balance'); + $this->assertSame(300_000, (int) $customer->fresh()->total); + + dispatch_sync(new ProvisionHostingAccountJob($item->fresh())); + + $this->assertSame('failed', $service->fresh()->status); + $this->assertSame(500_000, (int) $customer->fresh()->total); + $this->assertSame(InvoiceStatus::REFUNDED->value, (int) $invoice->fresh()->status); + $this->assertSame(OrderStatus::CANCELLED->value, $order->fresh()->status); + Bus::assertDispatched(VMCreationFailedJob::class); + } +} diff --git a/tests/Feature/TemplateSeederTest.php b/tests/Feature/TemplateSeederTest.php index a8431dc..f6b7056 100644 --- a/tests/Feature/TemplateSeederTest.php +++ b/tests/Feature/TemplateSeederTest.php @@ -42,7 +42,7 @@ class TemplateSeederTest extends TestCase } } - public function test_service_lifecycle_templates_are_seeded_once_as_marketing(): void + public function test_service_lifecycle_templates_are_seeded_once_and_are_not_marketing(): void { $items = ServiceLifecycleTemplates::all(); $this->assertCount(39, $items); @@ -53,11 +53,23 @@ class TemplateSeederTest extends TestCase foreach ($items as $item) { $this->assertSame(1, Template::where('name', $item['name'])->count(), $item['name']); $template = Template::where('name', $item['name'])->first(); - $this->assertTrue($template->isMarketing(), $item['name']); + $this->assertFalse($template->isMarketing(), $item['name']); $this->assertStringContainsString('class="Footer"', $template->message, $item['name']); } } + public function test_only_promotional_templates_are_marketing_and_respect_customer_opt_out(): void + { + $this->seed(TemplateSeeder::class); + + $marketing = Template::whereNull('key')->whereNull('scenario')->get() + ->filter(fn (Template $template) => $template->isMarketing()) + ->pluck('name')->sort()->values()->all(); + + $this->assertSame(['Khuyến mãi tháng 10', 'Ra mắt Premium Hosting'], $marketing); + $this->assertFalse(Template::where('name', 'Nhắc gia hạn dịch vụ - Thanh toán QR')->firstOrFail()->isMarketing()); + } + public function test_generic_new_service_template_fills_customer_email_and_hides_empty_access_rows(): void { $this->seed(TemplateSeeder::class);