feat(admin): improve payments and server settings

This commit is contained in:
baodev0
2026-10-19 07:59:23 +07:00
parent 9ed77a85fd
commit ea9a53f742
32 changed files with 680 additions and 78 deletions
+25
View File
@@ -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) {
+43
View File
@@ -0,0 +1,43 @@
<?php
declare(strict_types=1);
namespace App\Events;
use Illuminate\Broadcasting\InteractsWithSockets;
use Illuminate\Broadcasting\PrivateChannel;
use Illuminate\Contracts\Broadcasting\ShouldBroadcastNow;
use Illuminate\Foundation\Events\Dispatchable;
use Illuminate\Queue\SerializesModels;
class AdminNotificationCreated implements ShouldBroadcastNow
{
use Dispatchable, InteractsWithSockets, SerializesModels;
public function __construct(
public readonly string $type,
public readonly string $title,
public readonly ?string $body = null,
public readonly ?string $link = null,
) {}
public function broadcastOn(): array
{
return [new PrivateChannel('admin-notifications')];
}
public function broadcastAs(): string
{
return 'admin.notification.created';
}
public function broadcastWith(): array
{
return [
'type' => $this->type,
'title' => $this->title,
'body' => $this->body,
'link' => $this->link,
];
}
}
@@ -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,
@@ -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',
@@ -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,
@@ -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(),
@@ -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
],
]);
}
}
}
@@ -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,
@@ -0,0 +1,49 @@
<?php
declare(strict_types=1);
namespace App\Jobs\Mail\Admin;
use App\Mail\Admin\AdminPaymentReceived;
use App\Models\Setting;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Bus\Dispatchable;
use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Queue\SerializesModels;
use Illuminate\Support\Facades\Log;
use Illuminate\Support\Facades\Mail;
class AdminPaymentReceivedJob implements ShouldQueue
{
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
public int $tries = 3;
public int $timeout = 60;
public function __construct(public readonly array $data) {}
public function handle(): void
{
$setting = Setting::where('key', 'setting')->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(),
]);
}
}
}
@@ -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',
};
}
}
@@ -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',
};
}
}
@@ -0,0 +1,33 @@
<?php
namespace App\Listeners\Mail;
use App\Support\MailSettings;
use Illuminate\Mail\Events\MessageSending;
class ApplySystemRecipients
{
public function handle(MessageSending $event): void
{
$recipients = MailSettings::recipients();
$existing = collect([
...$event->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;
}
}
}
}
+2 -1
View File
@@ -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)
+42
View File
@@ -0,0 +1,42 @@
<?php
declare(strict_types=1);
namespace App\Mail\Admin;
use App\Mail\TemplateMailable;
use Illuminate\Bus\Queueable;
use Illuminate\Queue\SerializesModels;
class AdminPaymentReceived extends TemplateMailable
{
use Queueable, SerializesModels;
public function __construct(public readonly array $data) {}
public function attachments(): array
{
return [];
}
protected function scenario(): string
{
return 'admin_payment_received';
}
protected function variables(): array
{
return [
'company_name' => $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'),
];
}
}
+6
View File
@@ -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');
+20 -2
View File
@@ -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(),
]);
}
}
}
+1 -1
View File
@@ -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',
];
+2
View File
@@ -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,
],
];
+45
View File
@@ -0,0 +1,45 @@
<?php
declare(strict_types=1);
namespace App\Services;
use App\Jobs\Mail\Admin\AdminPaymentReceivedJob;
use App\Models\Invoice;
use App\Models\Notification;
class AdminPaymentNotifier
{
public function notify(Invoice $invoice, int $amount, string $paymentType): void
{
$invoice->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();
}
}
+18 -38
View File
@@ -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.
*
+2 -2
View File
@@ -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, [
+4
View File
@@ -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'],
+28
View File
@@ -49,6 +49,19 @@ final class MailSettings
return self::resolve()['transport'] ?? null;
}
/**
* @return array{cc: array<int, string>, bcc: array<int, string>}
*/
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<int, string>
*/
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,
)));
}
}
@@ -0,0 +1,22 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::table('dns_manager_servers', function (Blueprint $table) {
$table->string('domain')->nullable()->after('ip_address');
});
}
public function down(): void
{
Schema::table('dns_manager_servers', function (Blueprint $table) {
$table->dropColumn('domain');
});
}
};
@@ -0,0 +1,50 @@
<?php
use App\Support\EmailTemplateShell;
use Illuminate\Database\Migrations\Migration;
use Illuminate\Support\Facades\DB;
return new class extends Migration
{
private const SCENARIO = 'admin_payment_received';
public function up(): void
{
if (DB::table('template')->where('scenario', self::SCENARIO)->exists()) {
return;
}
$body = EmailTemplateShell::title('Khách hàng vừa thanh toán')
. EmailTemplateShell::paragraph('Hệ thống <strong>{{company_name}}</strong> 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();
}
};
@@ -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 <strong>{{company_name}}</strong> 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',
@@ -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'],
@@ -0,0 +1,51 @@
<?php
declare(strict_types=1);
namespace Tests\Feature\Api\Admin\DnsManager;
use App\Models\DnsServer;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Spatie\Permission\Models\Role;
use Tests\TestCase;
class DnsServerDomainTest extends TestCase
{
use RefreshDatabase;
private function admin(): User
{
Role::firstOrCreate(['name' => 'Full Administrator', 'guard_name' => 'admin']);
$admin = User::factory()->create();
$admin->assignRole('Full Administrator');
return $admin;
}
public function test_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']);
}
}
@@ -0,0 +1,88 @@
<?php
declare(strict_types=1);
namespace Tests\Feature\Api\Service;
use App\Enums\Invoice\InvoiceStatus;
use App\Enums\Order\OrderStatus;
use App\Jobs\Mail\Admin\AdminPaymentReceivedJob;
use App\Models\Customer;
use App\Models\Invoice;
use App\Models\Order;
use App\Services\Api\Order\OrderService;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Bus;
use Tests\TestCase;
class OrderPaymentAdminNotificationTest extends TestCase
{
use RefreshDatabase;
private function orderFor(Customer $customer, int $invoiceStatus): Order
{
$order = Order::create([
'order_num' => '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);
}
}
@@ -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',
);
}
}
@@ -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()));
}
}
@@ -0,0 +1,32 @@
<?php
declare(strict_types=1);
namespace Tests\Unit;
use App\Enums\Product\ProductBillingCycle;
use PHPUnit\Framework\TestCase;
class ProductBillingCycleLabelTest extends TestCase
{
public function test_code_is_translated_to_vietnamese_label(): void
{
$this->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));
}
}