From ea9a53f7423ded8166e45fd5eff7ac321b6c8e0c Mon Sep 17 00:00:00 2001 From: baodev0 Date: Mon, 19 Oct 2026 07:59:23 +0700 Subject: [PATCH] feat(admin): improve payments and server settings --- app/Enums/Product/ProductBillingCycle.php | 25 ++++++ app/Events/AdminNotificationCreated.php | 43 +++++++++ .../Api/Admin/DnsManager/ServerController.php | 7 ++ .../Api/Admin/Setting/SettingController.php | 4 + .../Api/Service/OrderControllerRefactor.php | 8 ++ .../Transaction/ManualDepositController.php | 28 +++++- .../Api/Transaction/TransactionController.php | 4 +- .../Service/ServiceClientDetailResource.php | 6 +- .../Mail/Admin/AdminPaymentReceivedJob.php | 49 +++++++++++ .../Provision/SendVmCredentialsEmailJob.php | 14 +-- .../Rebuild/RebuildFinalizeJob.php | 18 +--- app/Listeners/Mail/ApplySystemRecipients.php | 33 +++++++ app/Mail/Admin/AdminOrderNotification.php | 3 +- app/Mail/Admin/AdminPaymentReceived.php | 42 +++++++++ app/Models/DnsServer.php | 6 ++ app/Models/Notification.php | 22 ++++- app/Models/Service.php | 2 +- app/Providers/EventServiceProvider.php | 2 + app/Services/AdminPaymentNotifier.php | 45 ++++++++++ app/Services/Api/Order/OrderService.php | 56 ++++-------- app/Services/Plesk/PleskHostingService.php | 4 +- app/Support/EmailScenarioRegistry.php | 4 + app/Support/MailSettings.php | 28 ++++++ ...dd_domain_to_dns_manager_servers_table.php | 22 +++++ ...2_seed_admin_payment_received_template.php | 50 +++++++++++ .../seeders/EmailTemplates/AdminTemplates.php | 18 ++++ .../EmailTemplates/TemplateVariables.php | 4 + .../Admin/DnsManager/DnsServerDomainTest.php | 51 +++++++++++ .../OrderPaymentAdminNotificationTest.php | 88 +++++++++++++++++++ .../Transaction/ManualDepositProofTest.php | 20 +++++ tests/Feature/Mail/MailSettingsQueueTest.php | 20 +++++ tests/Unit/ProductBillingCycleLabelTest.php | 32 +++++++ 32 files changed, 680 insertions(+), 78 deletions(-) create mode 100644 app/Events/AdminNotificationCreated.php create mode 100644 app/Jobs/Mail/Admin/AdminPaymentReceivedJob.php create mode 100644 app/Listeners/Mail/ApplySystemRecipients.php create mode 100644 app/Mail/Admin/AdminPaymentReceived.php create mode 100644 app/Services/AdminPaymentNotifier.php create mode 100644 database/migrations/2026_10_17_000001_add_domain_to_dns_manager_servers_table.php create mode 100644 database/migrations/2026_10_17_000002_seed_admin_payment_received_template.php create mode 100644 tests/Feature/Api/Admin/DnsManager/DnsServerDomainTest.php create mode 100644 tests/Feature/Api/Service/OrderPaymentAdminNotificationTest.php create mode 100644 tests/Unit/ProductBillingCycleLabelTest.php diff --git a/app/Enums/Product/ProductBillingCycle.php b/app/Enums/Product/ProductBillingCycle.php index ac150fe..c8be702 100644 --- a/app/Enums/Product/ProductBillingCycle.php +++ b/app/Enums/Product/ProductBillingCycle.php @@ -25,6 +25,31 @@ enum ProductBillingCycle: string }; } + public function vietnameseLabel(): string + { + return match ($this) { + self::ONE_TIME => 'Một lần', + self::MONTHLY => 'Hàng tháng', + self::QUARTERLY => '3 tháng', + self::SEMI_ANNUALLY => '6 tháng', + self::ANNUALLY => 'Hàng năm', + self::BIENNIALLY => '2 năm', + self::TRIENNIALLY => '3 năm', + }; + } + + public static function vietnameseLabelFor(?string $code): string + { + return self::tryFrom((string) $code)?->vietnameseLabel() ?? ($code ?: 'N/A'); + } + + public static function vietnameseLabelForMonths(int $months): string + { + $cycle = collect(self::cases())->first(fn (self $case) => $case->months() === $months && $months > 0); + + return $cycle?->vietnameseLabel() ?? $months . ' tháng'; + } + public function months(): int { return match ($this) { diff --git a/app/Events/AdminNotificationCreated.php b/app/Events/AdminNotificationCreated.php new file mode 100644 index 0000000..cd37175 --- /dev/null +++ b/app/Events/AdminNotificationCreated.php @@ -0,0 +1,43 @@ + $this->type, + 'title' => $this->title, + 'body' => $this->body, + 'link' => $this->link, + ]; + } +} diff --git a/app/Http/Controllers/Api/Admin/DnsManager/ServerController.php b/app/Http/Controllers/Api/Admin/DnsManager/ServerController.php index 17ff128..7d4e0e4 100644 --- a/app/Http/Controllers/Api/Admin/DnsManager/ServerController.php +++ b/app/Http/Controllers/Api/Admin/DnsManager/ServerController.php @@ -12,6 +12,8 @@ use Illuminate\Support\Facades\DB; class ServerController extends Controller { + private const DOMAIN_PATTERN = '/^(?=.{1,253}$)([a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?\.)+[a-z]{2,63}$/i'; + public function __construct( private readonly DnsServerRepositoryInterface $serverRepository, private readonly ServerService $serverService, @@ -30,6 +32,7 @@ class ServerController extends Controller $query->where(function ($subQuery) use ($filter) { $subQuery->where('name', 'like', "%{$filter}%") ->orWhere('ip_address', 'like', "%{$filter}%") + ->orWhere('domain', 'like', "%{$filter}%") ->orWhere('username', 'like', "%{$filter}%"); }); } @@ -79,6 +82,7 @@ class ServerController extends Controller $validated = $request->validate([ 'name' => 'required|string|max:255', 'ip_address' => 'required|string|max:255', + 'domain' => ['nullable', 'string', 'max:255', 'regex:' . self::DOMAIN_PATTERN], 'panel_port' => 'nullable|integer|min:1|max:65535', 'username' => 'required|string|max:255', 'password' => 'required|string|max:255', @@ -93,6 +97,7 @@ class ServerController extends Controller $server = $this->serverRepository->create([ 'name' => $validated['name'], 'ip_address' => $validated['ip_address'], + 'domain' => $validated['domain'] ?? null, 'panel_port' => $validated['panel_port'] ?? 8880, 'username' => $validated['username'], 'password' => Crypt::encryptString($validated['password']), @@ -125,6 +130,7 @@ class ServerController extends Controller $validated = $request->validate([ 'name' => 'sometimes|required|string|max:255', 'ip_address' => 'sometimes|required|string|max:255', + 'domain' => ['nullable', 'string', 'max:255', 'regex:' . self::DOMAIN_PATTERN], 'panel_port' => 'nullable|integer|min:1|max:65535', 'username' => 'sometimes|required|string|max:255', 'password' => 'nullable|string|max:255', @@ -194,6 +200,7 @@ class ServerController extends Controller 'id' => $server->id, 'name' => $server->name, 'ip_address' => $server->ip_address, + 'domain' => $server->domain, 'panel_port' => $server->panel_port, 'username' => $server->username, 'api_key' => $server->api_key, diff --git a/app/Http/Controllers/Api/Admin/Setting/SettingController.php b/app/Http/Controllers/Api/Admin/Setting/SettingController.php index 15e4884..9ade6e4 100644 --- a/app/Http/Controllers/Api/Admin/Setting/SettingController.php +++ b/app/Http/Controllers/Api/Admin/Setting/SettingController.php @@ -101,6 +101,10 @@ class SettingController extends Controller 'smtp.encryption' => 'sometimes|in:ssl,tls,starttls,', 'smtp.from_name' => 'sometimes|string|nullable', 'smtp.from_address' => 'sometimes|email|nullable', + 'smtp.cc' => 'sometimes|array', + 'smtp.cc.*' => 'email|max:255|distinct', + 'smtp.bcc' => 'sometimes|array', + 'smtp.bcc.*' => 'email|max:255|distinct', 'turnstile' => 'sometimes|array', 'turnstile.enabled' => 'sometimes|boolean', 'turnstile.site_key' => 'sometimes|string|nullable|max:255', diff --git a/app/Http/Controllers/Api/Service/OrderControllerRefactor.php b/app/Http/Controllers/Api/Service/OrderControllerRefactor.php index db52c6e..53420e2 100644 --- a/app/Http/Controllers/Api/Service/OrderControllerRefactor.php +++ b/app/Http/Controllers/Api/Service/OrderControllerRefactor.php @@ -20,6 +20,7 @@ use App\Services\Api\Order\OrderQueryService; use App\Services\Api\Order\OrderService; use App\Services\InvoicePaymentService; use App\Services\InvoicePdfService; +use App\Services\AdminPaymentNotifier; use Illuminate\Http\JsonResponse; use Illuminate\Http\Request; use Symfony\Component\HttpFoundation\Response; @@ -32,6 +33,7 @@ class OrderControllerRefactor extends Controller private readonly OrderQueryService $orderQueryService, private readonly ActivityLogger $activityLogger, private readonly InvoicePaymentService $invoicePaymentService, + private readonly AdminPaymentNotifier $adminPaymentNotifier, ) {} public function getList(Request $request): JsonResponse @@ -234,6 +236,7 @@ class OrderControllerRefactor extends Controller ]); try { + $amountPaidBefore = (int) $invoice->amount_paid; $paidInvoice = $this->invoicePaymentService->payWithBalance( $invoice, $request->user(), @@ -250,6 +253,11 @@ class OrderControllerRefactor extends Controller ); $outstanding = \App\Services\InvoicePaymentService::outstanding($paidInvoice); + $this->adminPaymentNotifier->notify( + $paidInvoice, + (int) $paidInvoice->amount_paid - $amountPaidBefore, + 'invoice', + ); return response()->json([ 'success' => true, diff --git a/app/Http/Controllers/Api/Transaction/ManualDepositController.php b/app/Http/Controllers/Api/Transaction/ManualDepositController.php index 7eb1acc..6b02156 100644 --- a/app/Http/Controllers/Api/Transaction/ManualDepositController.php +++ b/app/Http/Controllers/Api/Transaction/ManualDepositController.php @@ -9,6 +9,7 @@ use App\Http\Services\Telegram; use App\Mail\Admin\ManualDepositPendingNotification; use App\Mail\Customer\ManualDepositStatus; use App\Models\Log; +use App\Models\Notification; use App\Models\Setting; use App\Models\Transaction; use App\Services\ActivityLogger; @@ -229,13 +230,34 @@ class ManualDepositController extends Controller ], 422); } + Notification::notifyAdmins([ + 'type' => 'manual_deposit_pending', + 'title' => 'Có yêu cầu nạp tiền chờ duyệt', + 'body' => sprintf( + '%s vừa gửi chứng từ nạp %s VNĐ (%s).', + $user->fullname, + number_format((float) $transaction->amount, 0, ',', '.'), + $transaction->public_code, + ), + 'link' => '/transactions', + 'data' => [ + 'transaction_uuid' => $transaction->uuid, + 'transaction_code' => $transaction->public_code, + 'customer_id' => $user->id, + 'amount' => (int) $transaction->amount, + ], + ]); + try { (new Telegram())->SendManualDepositPendingApproval($user, $transaction); - $this->sendManualDepositAdminEmail($user, $transaction, $request); - } catch (\Exception) { - // Không để lỗi Telegram ảnh hưởng response + } catch (\Throwable $e) { + \Log::error('Không thể gửi Telegram thông báo nạp tiền chờ duyệt: ' . $e->getMessage(), [ + 'transaction_code' => $transaction->public_code, + ]); } + $this->sendManualDepositAdminEmail($user, $transaction, $request); + Log::create([ 'agent' => 'api', 'client_ip' => $request->ip(), diff --git a/app/Http/Controllers/Api/Transaction/TransactionController.php b/app/Http/Controllers/Api/Transaction/TransactionController.php index 06abdbd..af48b9e 100644 --- a/app/Http/Controllers/Api/Transaction/TransactionController.php +++ b/app/Http/Controllers/Api/Transaction/TransactionController.php @@ -267,6 +267,7 @@ class TransactionController extends Controller 'public_code' => $t->public_code, 'trans' => $t->trans, 'description' => $t->typeLabel(), + 'direction' => $t->direction, 'amount' => (float) $t->amount, 'status' => $t->status, 'status_label' => TransactionStatus::from((int) $t->status)->label(), @@ -283,6 +284,7 @@ class TransactionController extends Controller 'public_code' => $inv->invoice_number, 'trans' => null, 'description' => 'Thanh toán đơn hàng: ' . ($inv->order?->orderItems->first()?->product?->name ?? $inv->order?->order_num), + 'direction' => 'debit', 'amount' => (float) $inv->total, 'status' => $inv->status, 'status_label' => \App\Enums\Invoice\InvoiceStatus::tryFrom((int) $inv->status)?->label() ?? '-', @@ -368,4 +370,4 @@ class TransactionController extends Controller ], ]); } -} \ No newline at end of file +} diff --git a/app/Http/Resources/Api/Client/Service/ServiceClientDetailResource.php b/app/Http/Resources/Api/Client/Service/ServiceClientDetailResource.php index 585fe84..00d55e8 100644 --- a/app/Http/Resources/Api/Client/Service/ServiceClientDetailResource.php +++ b/app/Http/Resources/Api/Client/Service/ServiceClientDetailResource.php @@ -126,9 +126,9 @@ class ServiceClientDetailResource extends JsonResource 'dns' => $vm->dns ?? null, ] : null, 'hosting' => $hosting ? [ - 'ip_address' => $hosting->server?->ip_address, - 'plesk_url' => $hosting->server?->ip_address - ? "http://{$hosting->server->ip_address}:{$hosting->server->panel_port}/" + 'ip_address' => $hosting->server?->publicHost(), + 'plesk_url' => $hosting->server + ? "http://{$hosting->server->publicHost()}:{$hosting->server->panel_port}/" : null, 'hosting_plan' => $hosting->hosting_plan, 'ftp_username' => $hosting->ftp_username, diff --git a/app/Jobs/Mail/Admin/AdminPaymentReceivedJob.php b/app/Jobs/Mail/Admin/AdminPaymentReceivedJob.php new file mode 100644 index 0000000..84a6ef8 --- /dev/null +++ b/app/Jobs/Mail/Admin/AdminPaymentReceivedJob.php @@ -0,0 +1,49 @@ +first(); + $values = $setting ? json_decode((string) $setting->value, true) : []; + $adminEmail = $values['admin_email'] ?? null; + + if (!$adminEmail) { + Log::warning('AdminPaymentReceivedJob: admin_email not configured in settings.'); + return; + } + + try { + Mail::to($adminEmail)->send(new AdminPaymentReceived([ + ...$this->data, + 'company_name' => $values['company'] ?? config('app.name'), + ])); + } catch (\Throwable $e) { + Log::error('AdminPaymentReceivedJob: failed to send email.', [ + 'reference' => $this->data['reference'] ?? null, + 'error' => $e->getMessage(), + ]); + } + } +} diff --git a/app/Jobs/VirtualMachine/Provision/SendVmCredentialsEmailJob.php b/app/Jobs/VirtualMachine/Provision/SendVmCredentialsEmailJob.php index bb464c5..d1bf97b 100644 --- a/app/Jobs/VirtualMachine/Provision/SendVmCredentialsEmailJob.php +++ b/app/Jobs/VirtualMachine/Provision/SendVmCredentialsEmailJob.php @@ -5,6 +5,7 @@ declare(strict_types=1); namespace App\Jobs\VirtualMachine\Provision; use App\Http\Services\Telegram; +use App\Enums\Product\ProductBillingCycle; use App\Jobs\Mail\Admin\AdminOrderNotificationJob; use App\Jobs\Mail\SendInformationVirtualMachine; use App\Models\Invoice; @@ -97,7 +98,7 @@ class SendVmCredentialsEmailJob implements ShouldQueue 'order_num' => $order?->order_num ?? $order?->id ?? 'N/A', 'product_name' => $orderItem?->getProductAttribute()?->name ?? 'Cloud VPS', 'product_type' => 'Cloud VPS', - 'billing_cycle' => $this->billingLabel($months), + 'billing_cycle' => ProductBillingCycle::vietnameseLabelForMonths($months), 'total' => $orderItem?->price ?? 0, 'created_at' => $order?->created_at?->format('d/m/Y H:i:s') ?? now()->format('d/m/Y H:i:s'), 'ip' => $vm->ip ?? '', @@ -171,15 +172,4 @@ class SendVmCredentialsEmailJob implements ShouldQueue // ───────────────────────────────────────────────────────────────────────── - private function billingLabel(?int $months): string - { - return match ($months) { - 1 => 'Hang thang', - 3 => '3 Thang', - 6 => '6 Thang', - 12 => 'Hang nam', - 24 => '2 Nam', - default => ($months ?? 0) . ' Thang', - }; - } } diff --git a/app/Jobs/VirtualMachine/Rebuild/RebuildFinalizeJob.php b/app/Jobs/VirtualMachine/Rebuild/RebuildFinalizeJob.php index b7e898c..e139a87 100644 --- a/app/Jobs/VirtualMachine/Rebuild/RebuildFinalizeJob.php +++ b/app/Jobs/VirtualMachine/Rebuild/RebuildFinalizeJob.php @@ -5,6 +5,7 @@ declare(strict_types=1); namespace App\Jobs\VirtualMachine\Rebuild; use App\Http\Services\Telegram; +use App\Enums\Product\ProductBillingCycle; use App\Jobs\Mail\Admin\AdminOrderNotificationJob; use App\Jobs\Mail\Customer\VmRebuildFailedJob; use App\Jobs\Mail\SendInformationVirtualMachine; @@ -157,7 +158,7 @@ final class RebuildFinalizeJob implements ShouldQueue 'company_logo' => $settingValue->logo ?? '', 'client_name' => $vm->customer->fullname ?? '', 'product_name' => $orderItem?->getProductAttribute()?->name ?? 'Cloud VPS', - 'product_billing' => $this->billingLabel($months), + 'product_billing' => ProductBillingCycle::vietnameseLabelForMonths($months), 'product_price' => $orderItem ? number_format((float) $orderItem->price, 0, ',', '.') : 'N/A', 'service_host' => $vm->ip ?? 'N/A', 'service_username' => $osUsername, @@ -180,7 +181,7 @@ final class RebuildFinalizeJob implements ShouldQueue 'order_num' => $vm->order?->order_num ?? $vm->order?->id ?? 'N/A', 'product_name' => ($orderItem?->getProductAttribute()?->name ?? 'Cloud VPS') . ' (Rebuild)', 'product_type' => 'Cloud VPS Rebuild', - 'billing_cycle' => $this->billingLabel($months), + 'billing_cycle' => ProductBillingCycle::vietnameseLabelForMonths($months), 'total' => $orderItem?->price ?? 0, 'created_at' => now()->format('d/m/Y H:i:s'), 'ip' => $vm->ip ?? '', @@ -188,17 +189,4 @@ final class RebuildFinalizeJob implements ShouldQueue 'password' => $vm->plain_password ?? '', ]); } - - private function billingLabel(int $months): string - { - return match ($months) { - 1 => 'Hang thang', - 3 => '3 Thang', - 6 => '6 Thang', - 12 => 'Hang nam', - 24 => '2 Nam', - default => $months . ' Thang', - }; - } - } diff --git a/app/Listeners/Mail/ApplySystemRecipients.php b/app/Listeners/Mail/ApplySystemRecipients.php new file mode 100644 index 0000000..958fc56 --- /dev/null +++ b/app/Listeners/Mail/ApplySystemRecipients.php @@ -0,0 +1,33 @@ +message->getTo(), + ...$event->message->getCc(), + ...$event->message->getBcc(), + ])->map(fn ($address) => strtolower($address->getAddress()))->all(); + + foreach ($recipients['cc'] as $email) { + if (!in_array($email, $existing, true)) { + $event->message->addCc($email); + $existing[] = $email; + } + } + + foreach ($recipients['bcc'] as $email) { + if (!in_array($email, $existing, true)) { + $event->message->addBcc($email); + $existing[] = $email; + } + } + } +} diff --git a/app/Mail/Admin/AdminOrderNotification.php b/app/Mail/Admin/AdminOrderNotification.php index 6221712..638d04e 100644 --- a/app/Mail/Admin/AdminOrderNotification.php +++ b/app/Mail/Admin/AdminOrderNotification.php @@ -4,6 +4,7 @@ declare(strict_types=1); namespace App\Mail\Admin; +use App\Enums\Product\ProductBillingCycle; use App\Mail\TemplateMailable; use App\Support\EmailTemplateShell; use Illuminate\Bus\Queueable; @@ -45,7 +46,7 @@ class AdminOrderNotification extends TemplateMailable : '', 'product_name' => $this->data['product_name'] ?? 'N/A', 'product_type' => $this->data['product_type'] ?? 'N/A', - 'billing_cycle' => $this->data['billing_cycle'] ?? 'N/A', + 'billing_cycle' => ProductBillingCycle::vietnameseLabelFor($this->data['billing_cycle'] ?? null), 'total_formatted' => number_format((float) ($this->data['total'] ?? 0), 0, ',', '.') . ' VNĐ', 'vm_block' => $vmRows ? EmailTemplateShell::heading('Thông tin dịch vụ Cloud VPS') . EmailTemplateShell::infoTable([], $vmRows) diff --git a/app/Mail/Admin/AdminPaymentReceived.php b/app/Mail/Admin/AdminPaymentReceived.php new file mode 100644 index 0000000..270fd7b --- /dev/null +++ b/app/Mail/Admin/AdminPaymentReceived.php @@ -0,0 +1,42 @@ + $this->data['company_name'] ?? config('app.name'), + 'payment_type' => $this->data['payment_type'] ?? 'Hóa đơn', + 'reference' => $this->data['reference'] ?? 'N/A', + 'invoice_number' => $this->data['invoice_number'] ?? 'N/A', + 'customer_name' => $this->data['customer_name'] ?? 'N/A', + 'customer_email' => $this->data['customer_email'] ?? 'N/A', + 'amount_formatted' => number_format((float) ($this->data['amount'] ?? 0), 0, ',', '.') . ' VNĐ', + 'outstanding_formatted' => number_format((float) ($this->data['outstanding'] ?? 0), 0, ',', '.') . ' VNĐ', + 'payment_status' => ($this->data['outstanding'] ?? 0) > 0 ? 'Thanh toán một phần' : 'Đã thanh toán đủ', + 'created_at' => $this->data['created_at'] ?? now()->format('d/m/Y H:i:s'), + ]; + } +} diff --git a/app/Models/DnsServer.php b/app/Models/DnsServer.php index e7ebc21..ca3bd15 100644 --- a/app/Models/DnsServer.php +++ b/app/Models/DnsServer.php @@ -16,6 +16,7 @@ class DnsServer extends Model protected $fillable = [ 'name', 'ip_address', + 'domain', 'panel_port', 'password', 'username', @@ -31,6 +32,11 @@ class DnsServer extends Model return in_array($type, [ServerType::HOSTING->value, ServerType::BOTH->value], true); } + public function publicHost(): string + { + return $this->domain ?: $this->ip_address; + } + protected function scopeIsActive($query) { return $query->where('status', 'activated'); diff --git a/app/Models/Notification.php b/app/Models/Notification.php index f6c9e87..e9a0649 100644 --- a/app/Models/Notification.php +++ b/app/Models/Notification.php @@ -2,11 +2,11 @@ namespace App\Models; +use App\Events\AdminNotificationCreated; use Illuminate\Database\Eloquent\Concerns\HasUuids; use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Model; -use App\Models\Customer; -use App\Models\User; +use Illuminate\Support\Facades\Log; class Notification extends Model { @@ -58,5 +58,23 @@ class Notification extends Model ...$attributes, ]); } + + if ($adminIds->isEmpty()) { + return; + } + + try { + broadcast(new AdminNotificationCreated( + type: (string) ($attributes['type'] ?? 'system'), + title: (string) ($attributes['title'] ?? 'Có thông báo mới'), + body: isset($attributes['body']) ? (string) $attributes['body'] : null, + link: isset($attributes['link']) ? (string) $attributes['link'] : null, + )); + } catch (\Throwable $e) { + Log::warning('Không thể phát thông báo realtime cho quản trị viên.', [ + 'type' => $attributes['type'] ?? null, + 'error' => $e->getMessage(), + ]); + } } } diff --git a/app/Models/Service.php b/app/Models/Service.php index cbfe85b..54e6a55 100644 --- a/app/Models/Service.php +++ b/app/Models/Service.php @@ -30,7 +30,7 @@ class Service extends Model protected $table = 'services'; public const HOSTING_ACCOUNT_RELATIONS = [ - 'hostingAccount.server:id,name,ip_address', + 'hostingAccount.server:id,name,ip_address,domain,panel_port', 'hostingAccount.domain:id,domain_name', ]; diff --git a/app/Providers/EventServiceProvider.php b/app/Providers/EventServiceProvider.php index 7f1bb5c..e87fef3 100644 --- a/app/Providers/EventServiceProvider.php +++ b/app/Providers/EventServiceProvider.php @@ -7,6 +7,7 @@ use Illuminate\Auth\Listeners\SendEmailVerificationNotification; use Illuminate\Foundation\Support\Providers\EventServiceProvider as ServiceProvider; use Illuminate\Mail\Events\MessageSending; use App\Listeners\Mail\CheckMailEnabled; +use App\Listeners\Mail\ApplySystemRecipients; class EventServiceProvider extends ServiceProvider { @@ -21,6 +22,7 @@ class EventServiceProvider extends ServiceProvider ], MessageSending::class => [ CheckMailEnabled::class, + ApplySystemRecipients::class, ], ]; diff --git a/app/Services/AdminPaymentNotifier.php b/app/Services/AdminPaymentNotifier.php new file mode 100644 index 0000000..5272ed4 --- /dev/null +++ b/app/Services/AdminPaymentNotifier.php @@ -0,0 +1,45 @@ +loadMissing(['customer', 'order']); + $outstanding = InvoicePaymentService::outstanding($invoice); + $reference = $invoice->order?->order_num ?? $invoice->invoice_number; + $label = $paymentType === 'order' ? 'đơn hàng' : 'hóa đơn'; + $amountFormatted = number_format($amount, 0, ',', '.') . ' VNĐ'; + + Notification::notifyAdmins([ + 'type' => 'payment_received', + 'title' => 'Khách hàng thanh toán ' . $label, + 'body' => "{$invoice->customer->fullname} đã thanh toán {$amountFormatted} cho {$label} #{$reference}.", + 'link' => '/order-invoice', + 'data' => [ + 'invoice_id' => $invoice->id, + 'order_id' => $invoice->order_id, + 'amount' => $amount, + 'outstanding' => $outstanding, + ], + ]); + + AdminPaymentReceivedJob::dispatch([ + 'payment_type' => $paymentType === 'order' ? 'Đơn hàng' : 'Hóa đơn', + 'reference' => $reference, + 'invoice_number' => $invoice->invoice_number, + 'customer_name' => (string) $invoice->customer->fullname, + 'customer_email' => (string) $invoice->customer->email, + 'amount' => $amount, + 'outstanding' => $outstanding, + 'created_at' => now()->format('d/m/Y H:i:s'), + ])->afterCommit(); + } +} diff --git a/app/Services/Api/Order/OrderService.php b/app/Services/Api/Order/OrderService.php index 52157e3..58e6ef3 100644 --- a/app/Services/Api/Order/OrderService.php +++ b/app/Services/Api/Order/OrderService.php @@ -2,19 +2,18 @@ namespace App\Services\Api\Order; +use App\Enums\Invoice\InvoiceStatus; use App\Enums\Order\OrderStatus; use App\Helpers\OrderHelper; -use App\Jobs\Mail\Admin\AdminOrderNotificationJob; use App\Models\Order; use App\Models\Product; use App\Repositories\Api\Invoice\InvoiceRepositoryInterface; use App\Repositories\Api\Order\OrderRepositoryInterface; use App\Repositories\Api\Service\ServiceRepository; use App\Services\InvoicePaymentService; +use App\Services\AdminPaymentNotifier; use App\Support\AddonQuantityValidator; -use Exception; use Illuminate\Support\Facades\DB; -use Illuminate\Support\Facades\Log; class OrderService { @@ -23,6 +22,7 @@ class OrderService protected ServiceRepository $productRepo, protected InvoiceRepositoryInterface $invoiceRepo, protected InvoicePaymentService $invoicePaymentService, + protected AdminPaymentNotifier $adminPaymentNotifier, ) {} // ========================================================================= @@ -110,7 +110,7 @@ class OrderService throw new \InvalidArgumentException('Đơn hàng đã được thanh toán hoặc đang xử lý.'); } - return DB::transaction(function () use ($order, $method) { + $processedOrder = DB::transaction(function () use ($order, $method) { $customer = $order->customer; if ($customer->total < $order->total) { @@ -121,22 +121,28 @@ class OrderService )); } - // Allow re-paying a cancelled order + $invoice = $order->invoice; + if (! $invoice) { + throw new \InvalidArgumentException('Không tìm thấy hóa đơn của đơn hàng.'); + } + + if ((int) $invoice->status === InvoiceStatus::PAID->value) { + throw new \InvalidArgumentException('Đơn hàng đã được thanh toán hoặc đang xử lý.'); + } + if ($order->status === OrderStatus::CANCELLED->value) { $order->update(['status' => OrderStatus::PENDING->value]); } - if (! $order->invoice) { - throw new \InvalidArgumentException('Không tìm thấy hóa đơn của đơn hàng.'); - } - - $this->invoicePaymentService->payWithBalance($order->invoice, $customer, $method); + $paidInvoice = $this->invoicePaymentService->payWithBalance($invoice, $customer, $method); $processedOrder = $order->fresh(['orderItems', 'invoice', 'customer']); - $this->dispatchAdminNotification($processedOrder, $customer); - return $processedOrder; }); + + $this->adminPaymentNotifier->notify($processedOrder->invoice, (int) $processedOrder->total, 'order'); + + return $processedOrder; } @@ -145,32 +151,6 @@ class OrderService // ========================================================================= - private function dispatchAdminNotification(Order $order, $customer): void - { - try { - $item = $order->orderItems->first(); - $product = $item?->product; - $productType = $product?->category?->name ?? $product?->type ?? 'N/A'; - - AdminOrderNotificationJob::dispatch([ - 'order_num' => $order->order_num, - 'customer_name' => (string) $customer->fullname, - 'customer_email' => $customer->email, - 'client_ip' => $order->client_ip ?? request()?->ip(), - 'product_name' => $product?->name ?? 'N/A', - 'product_type' => $productType, - 'billing_cycle' => $item?->billing_cycle ?? 'N/A', - 'total' => $order->total, - 'created_at' => now()->format('d/m/Y H:i:s'), - ]); - } catch (Exception $e) { - Log::error('OrderService: failed to dispatch admin notification', [ - 'order_id' => $order->id, - 'error' => $e->getMessage(), - ]); - } - } - /** * Enrich raw addon entries with resolved price & subtotal. * diff --git a/app/Services/Plesk/PleskHostingService.php b/app/Services/Plesk/PleskHostingService.php index 10c520e..da75e8e 100644 --- a/app/Services/Plesk/PleskHostingService.php +++ b/app/Services/Plesk/PleskHostingService.php @@ -137,12 +137,12 @@ class PleskHostingService 'plesk_login' => $pleskLogin, 'plesk_password' => $pleskPassword, // Plesk's actual login page — not the :8443 REST API entry point. - 'plesk_panel_url' => 'https://' . $server->ip_address . '/login_up.php', + 'plesk_panel_url' => 'https://' . $server->publicHost() . '/login_up.php', 'created_at' => now()->format('d/m/Y H:i:s'), ]; dispatch(new HostingAccountCreatedJob(array_merge($shared, [ - 'server_host' => $server->ip_address, + 'server_host' => $server->publicHost(), ]))); dispatch(new HostingAccountCreatedAdminNotificationJob(array_merge($shared, [ diff --git a/app/Support/EmailScenarioRegistry.php b/app/Support/EmailScenarioRegistry.php index 89ecd2d..5d0034f 100644 --- a/app/Support/EmailScenarioRegistry.php +++ b/app/Support/EmailScenarioRegistry.php @@ -153,6 +153,10 @@ final class EmailScenarioRegistry 'label' => '[Admin] Đơn hàng mới', 'category' => self::INTERNAL, 'group' => 'ORDER', 'required_variables' => ['order_num'], ], + 'admin_payment_received' => [ + 'label' => '[Admin] Khách hàng thanh toán', 'category' => self::INTERNAL, 'group' => 'BILLING', + 'required_variables' => ['reference', 'amount_formatted'], + ], 'admin_domain_order_new' => [ 'label' => '[Admin] Đơn đăng ký tên miền mới', 'category' => self::INTERNAL, 'group' => 'ORDER', 'required_variables' => ['domain_name', 'order_num'], diff --git a/app/Support/MailSettings.php b/app/Support/MailSettings.php index baafac6..fa8cf6f 100644 --- a/app/Support/MailSettings.php +++ b/app/Support/MailSettings.php @@ -49,6 +49,19 @@ final class MailSettings return self::resolve()['transport'] ?? null; } + /** + * @return array{cc: array, bcc: array} + */ + public static function recipients(): array + { + $settings = self::resolve(); + + return [ + 'cc' => $settings['cc'] ?? [], + 'bcc' => $settings['bcc'] ?? [], + ]; + } + public static function refresh(object $mailer): void { self::apply(); @@ -91,6 +104,8 @@ final class MailSettings 'transport' => $transport, 'from_address' => ($mail->from_address ?? null) ?: ($mail->user ?? null), 'from_name' => ($mail->from_name ?? null) ?: $company, + 'cc' => self::emailList($mail->cc ?? []), + 'bcc' => self::emailList($mail->bcc ?? []), 'mailer' => array_filter([ 'transport' => $transport, 'host' => $mail->host ?? '', @@ -102,4 +117,17 @@ final class MailSettings ]; }); } + + /** + * @return array + */ + private static function emailList(mixed $value): array + { + $emails = is_array($value) ? $value : preg_split('/[,;\s]+/', (string) $value, -1, PREG_SPLIT_NO_EMPTY); + + return array_values(array_unique(array_filter( + array_map(fn ($email) => strtolower(trim((string) $email)), $emails ?: []), + fn (string $email) => filter_var($email, FILTER_VALIDATE_EMAIL) !== false, + ))); + } } diff --git a/database/migrations/2026_10_17_000001_add_domain_to_dns_manager_servers_table.php b/database/migrations/2026_10_17_000001_add_domain_to_dns_manager_servers_table.php new file mode 100644 index 0000000..ec45e28 --- /dev/null +++ b/database/migrations/2026_10_17_000001_add_domain_to_dns_manager_servers_table.php @@ -0,0 +1,22 @@ +string('domain')->nullable()->after('ip_address'); + }); + } + + public function down(): void + { + Schema::table('dns_manager_servers', function (Blueprint $table) { + $table->dropColumn('domain'); + }); + } +}; diff --git a/database/migrations/2026_10_17_000002_seed_admin_payment_received_template.php b/database/migrations/2026_10_17_000002_seed_admin_payment_received_template.php new file mode 100644 index 0000000..45f765c --- /dev/null +++ b/database/migrations/2026_10_17_000002_seed_admin_payment_received_template.php @@ -0,0 +1,50 @@ +where('scenario', self::SCENARIO)->exists()) { + return; + } + + $body = EmailTemplateShell::title('Khách hàng vừa thanh toán') + . EmailTemplateShell::paragraph('Hệ thống {{company_name}} vừa ghi nhận một khoản thanh toán mới.') + . EmailTemplateShell::infoTable([ + 'Loại thanh toán' => '{{payment_type}}', + 'Mã tham chiếu' => '#{{reference}}', + 'Số hóa đơn' => '{{invoice_number}}', + 'Khách hàng' => '{{customer_name}}', + 'Email' => '{{customer_email}}', + 'Số tiền vừa trả' => '{{amount_formatted}}', + 'Còn thiếu' => '{{outstanding_formatted}}', + 'Trạng thái' => '{{payment_status}}', + 'Thời gian' => '{{created_at}}', + ]); + + DB::table('template')->insert([ + 'type_id' => 0, + 'scenario' => self::SCENARIO, + 'key' => self::SCENARIO, + 'format' => 'html', + 'name' => '[Admin] Khách hàng thanh toán', + 'subject' => '[ADMIN] Khách hàng thanh toán {{payment_type}} #{{reference}}', + 'message' => EmailTemplateShell::wrap(EmailTemplateShell::body($body)), + 'metadata' => json_encode(['variables' => []], JSON_UNESCAPED_UNICODE), + 'status' => 1, + 'created_at' => now(), + 'updated_at' => now(), + ]); + } + + public function down(): void + { + DB::table('template')->where('scenario', self::SCENARIO)->delete(); + } +}; diff --git a/database/seeders/EmailTemplates/AdminTemplates.php b/database/seeders/EmailTemplates/AdminTemplates.php index 61d9095..9d324f6 100644 --- a/database/seeders/EmailTemplates/AdminTemplates.php +++ b/database/seeders/EmailTemplates/AdminTemplates.php @@ -12,6 +12,24 @@ final class AdminTemplates public static function all(): array { return [ + [ + 'scenario' => 'admin_payment_received', + 'name' => '[Admin] Khách hàng thanh toán', + 'subject' => '[ADMIN] Khách hàng thanh toán {{payment_type}} #{{reference}}', + 'body' => T::title('Khách hàng vừa thanh toán') + . T::paragraph('Hệ thống {{company_name}} vừa ghi nhận một khoản thanh toán mới.') + . T::infoTable([ + 'Loại thanh toán' => '{{payment_type}}', + 'Mã tham chiếu' => '#{{reference}}', + 'Số hóa đơn' => '{{invoice_number}}', + 'Khách hàng' => '{{customer_name}}', + 'Email' => '{{customer_email}}', + 'Số tiền vừa trả' => '{{amount_formatted}}', + 'Còn thiếu' => '{{outstanding_formatted}}', + 'Trạng thái' => '{{payment_status}}', + 'Thời gian' => '{{created_at}}', + ]), + ], [ 'scenario' => 'admin_order_notification', 'name' => '[Admin] Đơn hàng mới', diff --git a/database/seeders/EmailTemplates/TemplateVariables.php b/database/seeders/EmailTemplates/TemplateVariables.php index ab79962..0db7f4c 100644 --- a/database/seeders/EmailTemplates/TemplateVariables.php +++ b/database/seeders/EmailTemplates/TemplateVariables.php @@ -103,6 +103,10 @@ final class TemplateVariables 'paid_row' => ['[Tự động] Dòng đã thanh toán', 'html', ''], 'transaction_code' => ['Mã giao dịch', 'string', 'NAP20261008001'], 'amount_formatted' => ['Số tiền', 'string', '500,000'], + 'payment_type' => ['Loại thanh toán', 'string', 'Hóa đơn'], + 'reference' => ['Mã tham chiếu', 'string', 'DH20261017001'], + 'outstanding_formatted' => ['Số tiền còn thiếu', 'string', '0 VNĐ'], + 'payment_status' => ['Trạng thái thanh toán', 'string', 'Đã thanh toán đủ'], 'credit_formatted' => ['Tiền khuyến mãi', 'string', '0'], 'current_total_formatted' => ['Số dư hiện tại', 'string', '1,500,000'], 'current_credit_formatted' => ['Số dư khuyến mãi hiện tại', 'string', '0'], diff --git a/tests/Feature/Api/Admin/DnsManager/DnsServerDomainTest.php b/tests/Feature/Api/Admin/DnsManager/DnsServerDomainTest.php new file mode 100644 index 0000000..7938f1a --- /dev/null +++ b/tests/Feature/Api/Admin/DnsManager/DnsServerDomainTest.php @@ -0,0 +1,51 @@ + 'Full Administrator', 'guard_name' => 'admin']); + $admin = User::factory()->create(); + $admin->assignRole('Full Administrator'); + + return $admin; + } + + public function test_public_host_prefers_domain_over_ip(): void + { + $server = new DnsServer(['ip_address' => '103.42.58.99', 'domain' => 'host99.tgs.com.vn']); + $this->assertSame('host99.tgs.com.vn', $server->publicHost()); + + $server->domain = null; + $this->assertSame('103.42.58.99', $server->publicHost()); + } + + public function test_store_saves_domain_and_rejects_invalid_hostname(): void + { + $admin = $this->admin(); + $payload = ['name' => 'Host 99', 'ip_address' => '103.42.58.99', 'username' => 'root', 'password' => 'secret']; + + $this->actingAs($admin, 'admin') + ->postJson('/api/v1/administrator/dns-servers', $payload + ['domain' => 'not a domain']) + ->assertStatus(422); + + $this->actingAs($admin, 'admin') + ->postJson('/api/v1/administrator/dns-servers', $payload + ['domain' => 'host99.tgs.com.vn']) + ->assertStatus(201) + ->assertJsonPath('data.domain', 'host99.tgs.com.vn'); + + $this->assertDatabaseHas('dns_manager_servers', ['ip_address' => '103.42.58.99', 'domain' => 'host99.tgs.com.vn']); + } +} diff --git a/tests/Feature/Api/Service/OrderPaymentAdminNotificationTest.php b/tests/Feature/Api/Service/OrderPaymentAdminNotificationTest.php new file mode 100644 index 0000000..7a442ed --- /dev/null +++ b/tests/Feature/Api/Service/OrderPaymentAdminNotificationTest.php @@ -0,0 +1,88 @@ + 'ORD-' . uniqid(), + 'customer_id' => $customer->id, + 'sub_total' => 100_000, + 'discount_amount' => 0, + 'total' => 100_000, + 'status' => OrderStatus::PENDING->value, + ]); + + 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, + ]); + + return $order->fresh(['invoice', 'customer']); + } + + public function test_successful_payment_notifies_admin_once(): void + { + Bus::fake([AdminPaymentReceivedJob::class]); + $customer = Customer::factory()->create(['total' => 200_000]); + $order = $this->orderFor($customer, InvoiceStatus::UNPAID->value); + + app(OrderService::class)->pay($order, 'balance'); + + Bus::assertDispatchedTimes(AdminPaymentReceivedJob::class, 1); + } + + public function test_insufficient_balance_does_not_notify_admin(): void + { + Bus::fake([AdminPaymentReceivedJob::class]); + $customer = Customer::factory()->create(['total' => 0]); + $order = $this->orderFor($customer, InvoiceStatus::UNPAID->value); + + try { + app(OrderService::class)->pay($order, 'balance'); + $this->fail('Expected InvalidArgumentException'); + } catch (\InvalidArgumentException) { + } + + Bus::assertNotDispatched(AdminPaymentReceivedJob::class); + } + + public function test_paying_an_already_paid_invoice_is_rejected_without_notifying_admin(): void + { + Bus::fake([AdminPaymentReceivedJob::class]); + $customer = Customer::factory()->create(['total' => 200_000]); + $order = $this->orderFor($customer, InvoiceStatus::PAID->value); + + try { + app(OrderService::class)->pay($order, 'balance'); + $this->fail('Expected InvalidArgumentException'); + } catch (\InvalidArgumentException) { + } + + Bus::assertNotDispatched(AdminPaymentReceivedJob::class); + $this->assertSame(200_000, (int) $customer->fresh()->total); + } +} diff --git a/tests/Feature/Api/Transaction/ManualDepositProofTest.php b/tests/Feature/Api/Transaction/ManualDepositProofTest.php index facd54b..c7c1248 100644 --- a/tests/Feature/Api/Transaction/ManualDepositProofTest.php +++ b/tests/Feature/Api/Transaction/ManualDepositProofTest.php @@ -5,12 +5,17 @@ declare(strict_types=1); namespace Tests\Feature\Api\Transaction; use App\Enums\TransactionStatus; +use App\Events\AdminNotificationCreated; use App\Models\Customer; use App\Models\Transaction; +use App\Models\User; use Illuminate\Foundation\Testing\RefreshDatabase; use Illuminate\Http\UploadedFile; +use Illuminate\Support\Facades\Event; +use Illuminate\Support\Facades\Mail; use Illuminate\Support\Facades\Storage; use Illuminate\Support\Str; +use Spatie\Permission\Models\Role; use Tests\TestCase; class ManualDepositProofTest extends TestCase @@ -108,8 +113,13 @@ class ManualDepositProofTest extends TestCase public function test_upload_then_confirm_binds_proof_to_owner(): void { Storage::fake('local'); + Mail::fake(); + Event::fake([AdminNotificationCreated::class]); $customer = Customer::factory()->create(); + Role::firstOrCreate(['name' => 'Full Administrator', 'guard_name' => 'admin']); + $admin = User::factory()->create(); + $admin->assignRole('Full Administrator'); $transaction = Transaction::create([ 'uuid' => Str::uuid()->toString(), @@ -138,5 +148,15 @@ class ManualDepositProofTest extends TestCase $confirm->assertStatus(200)->assertJsonPath('error', false); $this->assertSame($path, $transaction->fresh()->proof_image); + $this->assertDatabaseHas('notifications', [ + 'recipient_type' => 'admin', + 'recipient_id' => $admin->id, + 'type' => 'manual_deposit_pending', + 'link' => '/transactions', + ]); + Event::assertDispatched( + AdminNotificationCreated::class, + fn (AdminNotificationCreated $event) => $event->type === 'manual_deposit_pending', + ); } } diff --git a/tests/Feature/Mail/MailSettingsQueueTest.php b/tests/Feature/Mail/MailSettingsQueueTest.php index 44e149c..336a8e8 100644 --- a/tests/Feature/Mail/MailSettingsQueueTest.php +++ b/tests/Feature/Mail/MailSettingsQueueTest.php @@ -7,8 +7,11 @@ namespace Tests\Feature\Mail; use App\Http\Facade\SignRSA; use App\Models\Setting; use App\Support\MailSettings; +use App\Listeners\Mail\ApplySystemRecipients; use Illuminate\Foundation\Testing\RefreshDatabase; +use Illuminate\Mail\Events\MessageSending; use Illuminate\Support\Facades\Cache; +use Symfony\Component\Mime\Email; use Tests\TestCase; class MailSettingsQueueTest extends TestCase @@ -45,4 +48,21 @@ class MailSettingsQueueTest extends TestCase $this->saveSmtp('smtp.settings-two.test'); $this->assertSame('smtp.settings-two.test', $this->hostSeenByQueuedJob()); } + + public function test_system_cc_and_bcc_are_applied_without_duplicate_recipients(): void + { + $this->saveSmtp('smtp.settings.test'); + $smtp = Setting::where('key', 'smtp')->firstOrFail(); + $values = json_decode((string) $smtp->value, true); + $values['cc'] = ['audit@example.com', 'customer@example.com']; + $values['bcc'] = ['archive@example.com', 'audit@example.com']; + $smtp->update(['value' => json_encode($values)]); + Cache::forget(MailSettings::cacheKey()); + + $message = (new Email())->to('customer@example.com')->subject('Test'); + app(ApplySystemRecipients::class)->handle(new MessageSending($message, [])); + + $this->assertSame(['audit@example.com'], array_map(fn ($address) => $address->getAddress(), $message->getCc())); + $this->assertSame(['archive@example.com'], array_map(fn ($address) => $address->getAddress(), $message->getBcc())); + } } diff --git a/tests/Unit/ProductBillingCycleLabelTest.php b/tests/Unit/ProductBillingCycleLabelTest.php new file mode 100644 index 0000000..7f431e2 --- /dev/null +++ b/tests/Unit/ProductBillingCycleLabelTest.php @@ -0,0 +1,32 @@ +assertSame('Hàng tháng', ProductBillingCycle::vietnameseLabelFor('monthly')); + $this->assertSame('6 tháng', ProductBillingCycle::vietnameseLabelFor('semi_annually')); + $this->assertSame('Hàng năm', ProductBillingCycle::vietnameseLabelFor('annually')); + } + + public function test_unknown_or_missing_code_falls_back_safely(): void + { + $this->assertSame('custom', ProductBillingCycle::vietnameseLabelFor('custom')); + $this->assertSame('N/A', ProductBillingCycle::vietnameseLabelFor(null)); + } + + public function test_months_are_translated_with_fallback(): void + { + $this->assertSame('Hàng tháng', ProductBillingCycle::vietnameseLabelForMonths(1)); + $this->assertSame('2 năm', ProductBillingCycle::vietnameseLabelForMonths(24)); + $this->assertSame('9 tháng', ProductBillingCycle::vietnameseLabelForMonths(9)); + $this->assertSame('0 tháng', ProductBillingCycle::vietnameseLabelForMonths(0)); + } +}