feat: email gửi tay/nhắc gia hạn, gói hosting, sửa dữ liệu sản phẩm

- Gửi email theo template cho 1 khách, tab lịch sử email, nhắc gia hạn dịch vụ hàng loạt
- Mail lưu from/bcc/sent_by; mẫu MARKETING và mẫu cũ trong TemplateSeeder
- Gói hosting: giới hạn, trạng thái, lấy plan Plesk qua CLI gateway
- Backfill payment_method và ngày đơn hàng còn thiếu
- Bổ sung field CPU/RAM/Disk/Băng thông cho loại sản phẩm vps và cloud

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
baodev0
2026-10-09 11:32:05 +07:00
co-authored by Claude Opus 5.5
parent 8651dde6b1
commit 30898ea46f
59 changed files with 1771 additions and 108 deletions
+3 -3
View File
@@ -10,7 +10,7 @@ enum ProductTypeEnums: string
case EMAIL = 'email';
case SSL = 'ssl';
case DOMAIN = 'domain';
case WEB_DESIGN = 'web design';
case WEB_DESIGN = 'web_design';
case ADDON = 'addon';
case HARDWARE = 'hardware';
case IT_SUPPORT = 'it_support';
@@ -24,7 +24,7 @@ enum ProductTypeEnums: string
self::CLOUD => 'Cloud',
self::EMAIL => 'Email',
self::SSL => 'SSL',
self::DOMAIN => 'Domain',
self::DOMAIN => 'Tên miền',
self::WEB_DESIGN => 'Web Design',
self::ADDON => 'Addon',
self::HARDWARE => 'Hardware',
@@ -49,4 +49,4 @@ enum ProductTypeEnums: string
self::SERVER_RENTAL => 'magenta',
};
}
}
}
+92
View File
@@ -0,0 +1,92 @@
<?php
namespace App\Exports;
use App\Models\Product;
use Illuminate\Support\Collection;
use Maatwebsite\Excel\Concerns\FromCollection;
use Maatwebsite\Excel\Concerns\ShouldAutoSize;
use Maatwebsite\Excel\Concerns\WithEvents;
use Maatwebsite\Excel\Concerns\WithHeadings;
use Maatwebsite\Excel\Concerns\WithMapping;
use Maatwebsite\Excel\Events\AfterSheet;
final class ProductsExport implements FromCollection, WithHeadings, WithMapping, WithEvents, ShouldAutoSize
{
public function collection(): Collection
{
return Product::query()
->with(['category', 'pricings', 'operatingSystems'])
->orderBy('id')
->get();
}
public function headings(): array
{
return [
'ID', 'UUID', 'Tên sản phẩm', 'Slug', 'Ảnh đại diện', 'Loại sản phẩm',
'ID danh mục', 'Danh mục', 'ID gói dịch vụ', 'CPU', 'RAM', 'Ổ đĩa',
'Băng thông', 'Số tên miền', 'Phí khởi tạo', 'Giá thấp nhất', 'Loại thanh toán',
'Tiền tệ', 'Giá tháng', 'Giá quý', 'Giá 6 tháng', 'Giá năm', 'Giá 2 năm',
'Giá 3 năm', 'Giá một lần', 'Tính năng', 'Đang hoạt động', 'ID hệ điều hành',
'ID máy chủ module', 'ID datastore module', 'Ngày tạo', 'Ngày cập nhật',
];
}
public function map($product): array
{
$prices = $product->pricings->keyBy('billing_cycle');
$hasOneTime = $prices->has('one_time');
$hasRecurring = $prices->keys()->contains(fn ($cycle) => $cycle !== 'one_time');
return [
$product->id,
$product->uuid,
$product->name,
$product->slug,
$product->thumbnail,
$product->product_type,
$product->product_categories_id,
$product->category?->name,
$product->service_plan_id,
$product->cpu,
$product->ram,
$product->disk,
$product->bandwidth,
$product->domain_count,
$product->setup_fee,
$prices->min('price'),
$hasOneTime && !$hasRecurring ? 'Một lần' : 'Định kỳ',
$prices->first()?->currency ?? 'VND',
$prices->get('monthly')?->price,
$prices->get('quarterly')?->price,
$prices->get('semi_annually')?->price,
$prices->get('annually')?->price,
$prices->get('biennially')?->price,
$prices->get('triennially')?->price,
$prices->get('one_time')?->price,
$product->features ? json_encode($product->features, JSON_UNESCAPED_UNICODE) : null,
$product->is_active ? 'Có' : 'Không',
$product->operatingSystems->pluck('id')->implode(', '),
$product->module_host_id,
$product->module_datastore_id,
optional($product->created_at)->format('Y-m-d H:i:s'),
optional($product->updated_at)->format('Y-m-d H:i:s'),
];
}
public function registerEvents(): array
{
return [
AfterSheet::class => function (AfterSheet $event): void {
$sheet = $event->sheet->getDelegate();
$sheet->freezePane('A2');
$sheet->setAutoFilter($sheet->calculateWorksheetDimension());
$sheet->getStyle('A1:AF1')->getFont()->setBold(true)->getColor()->setARGB('FFFFFFFF');
$sheet->getStyle('A1:AF1')->getFill()->setFillType('solid')->getStartColor()->setARGB('FF1677FF');
$sheet->getStyle('A1:AF1')->getAlignment()->setVertical('center');
$sheet->getRowDimension(1)->setRowHeight(24);
},
];
}
}
@@ -0,0 +1,68 @@
<?php
namespace App\Http\Controllers\Api\Admin\Customer\Concerns;
use App\Models\Mail;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
trait ListsCustomerEmails
{
public function emails(Request $request, string $uuid): JsonResponse
{
$customer = $this->resolveCustomer($uuid);
$search = trim((string) $request->get('search', ''));
$paginator = Mail::query()
->with(['sender', 'template:id,name'])
->where('customer_id', $customer->id)
->when($search !== '', fn ($q) => $q->where(fn ($q) => $q
->where('subject', 'like', "%{$search}%")
->orWhere('to', 'like', "%{$search}%")))
->latest('id')
->paginate(min((int) $request->get('per_page', 10), 100), ['id', 'template_id', 'subject', 'from_address', 'to', 'cc', 'bcc', 'sent_by', 'status', 'created_at']);
return response()->json([
'error' => false,
'data' => [
'items' => $paginator->getCollection()->map($this->mapCustomerEmail(...))->all(),
'total' => $paginator->total(),
'current_page' => $paginator->currentPage(),
'per_page' => $paginator->perPage(),
],
]);
}
public function emailDetail(string $uuid, int $id): JsonResponse
{
$customer = $this->resolveCustomer($uuid);
$mail = Mail::with(['sender', 'template:id,name'])
->where('customer_id', $customer->id)
->find($id);
if (!$mail) {
return response()->json(['error' => true, 'message' => 'Không tìm thấy email'], 404);
}
return response()->json([
'error' => false,
'data' => [...$this->mapCustomerEmail($mail), 'html' => $mail->message],
]);
}
private function mapCustomerEmail(Mail $mail): array
{
return [
'id' => $mail->id,
'subject' => $mail->subject,
'from_address' => $mail->from_address,
'sent_by' => $mail->sender ? ($mail->sender->name ?: $mail->sender->username) : null,
'to' => $mail->to,
'cc' => $mail->cc,
'bcc' => $mail->bcc,
'template' => $mail->template?->name,
'status' => $mail->status,
'created_at' => $mail->created_at,
];
}
}
@@ -34,6 +34,26 @@ trait ManagesCustomerAccount
return response()->json(['error' => false, 'message' => 'Đã gửi mật khẩu mới cho khách hàng.']);
}
public function emailPreview(Request $request, string $uuid): JsonResponse
{
$data = $request->validate(['template_id' => 'required|integer']);
return response()->json([
'error' => false,
'data' => $this->mailService->previewTemplateEmail($uuid, (int) $data['template_id']),
]);
}
public function emailLayout(Request $request, string $uuid): JsonResponse
{
$data = $request->validate(['message' => 'nullable|string']);
return response()->json([
'error' => false,
'data' => ['html' => $this->mailService->wrapWithLayout($uuid, (string) ($data['message'] ?? ''))],
]);
}
public function sendEmail(Request $request, string $uuid): JsonResponse
{
$data = $request->validate([
@@ -43,6 +63,7 @@ trait ManagesCustomerAccount
'to' => 'nullable|string|max:1000',
'cc' => 'nullable|string|max:1000',
'bcc' => 'nullable|string|max:1000',
'with_layout' => 'nullable|boolean',
]);
$this->mailService->sendCustomEmail($uuid, $data, Auth::guard('admin')->user(), $request->ip());
@@ -35,6 +35,7 @@ trait ManagesCustomerServices
'requirements' => 'nullable|string',
'feedback' => 'nullable|string',
'price' => 'nullable|numeric|min:0',
'billing_cycle' => 'nullable|in:monthly,quarterly,semi_annually,annually,biennially,triennially',
'virtual_machine_id' => 'nullable|integer|exists:virtual_machines,id',
'os_id' => 'nullable|integer|exists:os,id',
'vm_cpu' => 'nullable|integer|min:1',
@@ -24,6 +24,7 @@ use App\Http\Controllers\Api\Admin\Customer\Concerns\ManagesCustomerDomains;
use App\Http\Controllers\Api\Admin\Customer\Concerns\ManagesCustomerServices;
use App\Http\Controllers\Api\Admin\Customer\Concerns\ManagesCustomerBulkActions;
use App\Http\Controllers\Api\Admin\Customer\Concerns\ManagesCustomerNotes;
use App\Http\Controllers\Api\Admin\Customer\Concerns\ListsCustomerEmails;
class CustomerController extends Controller
{
@@ -34,6 +35,7 @@ class CustomerController extends Controller
use ManagesCustomerServices;
use ManagesCustomerBulkActions;
use ManagesCustomerNotes;
use ListsCustomerEmails;
public function __construct(
private readonly CustomerRepositoryInterface $customerRepository,
@@ -16,6 +16,8 @@ use App\Repositories\Api\Domain\DomainOrderRepository;
use App\Services\Api\Domain\DomainOrderService;
use App\Services\Api\Domain\DomainRegistrantService;
use App\Services\Email\EmailTemplateService;
use App\Support\EmailTemplateShell;
use App\Support\MailGlobals;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Http\Response;
@@ -278,22 +280,46 @@ class AdminDomainOrderController extends Controller
return;
}
$setting = \App\Models\Setting::where('key', 'setting')->first();
$settingValue = $setting ? json_decode((string) $setting->value, true) : [];
app(EmailTemplateService::class)->send($scenario, $customer->email, [
'company_name' => $settingValue['company'] ?? config('app.name'),
'company_logo' => $settingValue['logo'] ?? '',
'domain_name' => $domainName,
'client_name' => $customer->fullname,
'order_num' => $orderNum,
'reason' => $reason,
...MailGlobals::values(),
...$this->domainHandoverVariables($order),
'domain_name' => $domainName,
'client_name' => $customer->fullname,
'customer_email' => $customer->email,
'order_num' => $orderNum,
'reason' => $reason,
]);
} catch (\Throwable $e) {
Log::error('AdminDomainOrderController: không thể gửi email khách hàng: ' . $e->getMessage());
}
}
/**
* @return array<string, string>
*/
private function domainHandoverVariables(Order $order): array
{
$service = $order->orderItems->first()?->service?->fresh();
if (!$service) {
return [];
}
return [
'registered_at' => $service->started_at?->format('d/m/Y') ?? '',
'expires_at' => $service->expires_at?->format('d/m/Y') ?? '',
'kyc_block' => $service->kyc_url ? $this->kycBlock($service->kyc_url) : '',
];
}
private function kycBlock(string $kycUrl): string
{
return EmailTemplateShell::heading('📋 Lưu ý quan trọng')
. EmailTemplateShell::paragraph('Căn cứ Khoản 2 Điều 9 và Khoản 6 Điều 21 Thông tư số 48/2025/TT-BKHCN ngày 25/12/2025 của Bộ Khoa học và Công nghệ về Quản lý và sử dụng tài nguyên Internet, chủ thể đăng ký tên miền là cá nhân có trách nhiệm hoàn thiện hồ sơ đăng ký thông qua hình thức xác thực điện tử (eKYC) trong vòng <strong>03 ngày</strong> kể từ thời điểm nhận được email này.')
. EmailTemplateShell::button(htmlspecialchars($kycUrl, ENT_QUOTES, 'UTF-8'), '✅ Xác thực eKYC ngay')
. EmailTemplateShell::note('Việc không hoàn thành xác thực đúng hạn có thể ảnh hưởng đến quyền sử dụng tên miền của Quý khách.', '⏰ Lưu ý:');
}
// =========================================================================
// Private formatters
// =========================================================================
@@ -3,15 +3,17 @@
namespace App\Http\Controllers\Api\Admin\Hosting;
use App\Http\Controllers\Controller;
use App\Http\Requests\Admin\Hosting\ServicePlanRequest;
use App\Models\DnsServer;
use App\Models\ServicePlan;
use App\Services\Plesk\PleskServicePlanService;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
class ServicePlanController extends Controller
{
public function index(): JsonResponse
{
$plans = ServicePlan::with('plesk', 'dnsServer')->orderBy('title')->get();
$plans = ServicePlan::with('plesk', 'dnsServer')->orderBy('sort_order')->orderBy('title')->get();
return response()->json(['error' => false, 'data' => $plans]);
}
@@ -27,29 +29,11 @@ class ServicePlanController extends Controller
return response()->json(['error' => false, 'data' => $plan]);
}
public function store(Request $request): JsonResponse
public function store(ServicePlanRequest $request): JsonResponse
{
$validated = $request->validate([
'title' => 'required|string|max:255',
'vcpu' => 'required|integer|min:0',
'ram' => 'required|integer|min:0',
'disk' => 'required|integer|min:0',
'domain' => 'required|integer|min:0',
'dns_manager_server_id' => 'nullable|integer|exists:dns_manager_servers,id',
'plesk_plan_name' => 'nullable|string|max:255',
'note' => 'nullable|string',
]);
$plan = ServicePlan::create($request->planAttributes());
$plan = ServicePlan::create([
'title' => $validated['title'],
'vcpu' => $validated['vcpu'],
'ram' => $validated['ram'],
'disk' => $validated['disk'],
'domain' => $validated['domain'],
'dns_manager_server_id' => $validated['dns_manager_server_id'] ?? null,
]);
$this->savePlesk($plan, $validated);
$this->savePlesk($plan, $request->validated());
return response()->json([
'error' => false,
@@ -58,7 +42,7 @@ class ServicePlanController extends Controller
], 201);
}
public function update(Request $request, int $id): JsonResponse
public function update(ServicePlanRequest $request, int $id): JsonResponse
{
$plan = ServicePlan::find($id);
@@ -66,27 +50,9 @@ class ServicePlanController extends Controller
return response()->json(['error' => true, 'message' => 'Không tìm thấy gói hosting.'], 404);
}
$validated = $request->validate([
'title' => 'required|string|max:255',
'vcpu' => 'required|integer|min:0',
'ram' => 'required|integer|min:0',
'disk' => 'required|integer|min:0',
'domain' => 'required|integer|min:0',
'dns_manager_server_id' => 'nullable|integer|exists:dns_manager_servers,id',
'plesk_plan_name' => 'nullable|string|max:255',
'note' => 'nullable|string',
]);
$plan->update($request->planAttributes());
$plan->update([
'title' => $validated['title'],
'vcpu' => $validated['vcpu'],
'ram' => $validated['ram'],
'disk' => $validated['disk'],
'domain' => $validated['domain'],
'dns_manager_server_id' => $validated['dns_manager_server_id'] ?? null,
]);
$this->savePlesk($plan, $validated);
$this->savePlesk($plan, $request->validated());
return response()->json([
'error' => false,
@@ -108,6 +74,23 @@ class ServicePlanController extends Controller
return response()->json(['error' => false, 'message' => 'Xoá gói hosting thành công.']);
}
public function pleskPlans(int $serverId, PleskServicePlanService $plans): JsonResponse
{
$server = DnsServer::find($serverId);
if (!$server || !$server->servesHosting()) {
return response()->json(['error' => true, 'message' => 'Server không hỗ trợ hosting.'], 404);
}
$names = $plans->names($server);
if ($names === null) {
return response()->json(['error' => true, 'message' => 'Không lấy được danh sách plan từ Plesk.'], 502);
}
return response()->json(['error' => false, 'data' => $names]);
}
private function savePlesk(ServicePlan $plan, array $data): void
{
if (empty($data['plesk_plan_name'])) {
@@ -28,6 +28,8 @@ trait HandlesInvoicePayments
// hoá đơn ghi nhận trả một phần và chưa kích hoạt dịch vụ.
$validated = $request->validate([
'received_amount' => 'nullable|integer|min:1',
'recording_mode' => 'nullable|string|in:manual,automatic',
'note' => 'nullable|string|max:1000',
]);
try {
@@ -37,7 +39,9 @@ trait HandlesInvoicePayments
$invoice = $this->invoicePaymentService->markPaidByAdmin(
$invoice,
auth()->id(),
method: $validated['recording_mode'] ?? 'manual',
receivedAmount: $validated['received_amount'] ?? null,
note: $validated['note'] ?? '',
);
DB::commit();
@@ -74,6 +74,36 @@ final class MailCampaignController extends Controller
]);
}
public function variableSuggestions(Request $request): JsonResponse
{
$data = $request->validate([
'template_id' => 'required|integer|exists:template,id',
]);
$suggestions = MailCampaign::query()
->where('template_id', $data['template_id'])
->whereNotNull('variables')
->where('sent_count', '>', 0)
->orderByDesc('created_at')
->limit(20)
->get(['uuid', 'name', 'variables', 'created_at'])
->filter(fn (MailCampaign $campaign) => !empty($campaign->variables))
->unique(fn (MailCampaign $campaign) => json_encode($campaign->variables))
->take(8)
->map(fn (MailCampaign $campaign) => [
'uuid' => $campaign->uuid,
'name' => $campaign->name,
'variables' => $campaign->variables,
'created_at' => $campaign->created_at,
])
->values();
return response()->json([
'error' => false,
'data' => $suggestions,
]);
}
public function preview(Request $request, MailCampaignRenderer $renderer): JsonResponse
{
$campaign = $this->draftCampaign($request->validate($this->contentRules()));
@@ -7,6 +7,7 @@ use App\Http\Requests\OrderRequest;
use App\Http\Requests\UpdateOrderRequest;
use App\Http\Resources\Api\Admin\OrderResource;
use App\Models\Order;
use App\Models\DomainPricing;
use App\Repositories\Order\OrderRepositoryInterface;
use App\Services\OrderService;
use Illuminate\Http\JsonResponse;
@@ -42,6 +43,14 @@ class OrderController extends Controller
]);
}
public function domainPricing(): JsonResponse
{
return response()->json([
'error' => false,
'data' => DomainPricing::toResponseArray(),
]);
}
public function store(OrderRequest $request): JsonResponse
{
try {
@@ -5,12 +5,24 @@ namespace App\Http\Controllers\Api\Admin\Product;
use App\Http\Controllers\Controller;
use App\Http\Resources\Api\Admin\ProductCategoryResource;
use App\Models\ProductCategories;
use App\Services\Product\ProductCategorySampleService;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Str;
class ProductCategoryController extends Controller
{
public function seedSample(ProductCategorySampleService $service): JsonResponse
{
$result = $service->seed();
return response()->json([
'error' => false,
'message' => "Đã tạo {$result['created']} và bổ sung thông tin cho {$result['updated']} nhóm dịch vụ.",
'data' => $result,
]);
}
public function index(Request $request): JsonResponse
{
$perPage = min((int) $request->get('per_page', 15), 100);
@@ -2,6 +2,7 @@
namespace App\Http\Controllers\Api\Admin\Product;
use App\Exports\ProductsExport;
use App\Http\Controllers\Controller;
use App\Http\Resources\Api\Admin\ProductResource;
use App\Http\Services\NextjsRevalidationService;
@@ -12,6 +13,9 @@ use App\Models\Os;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Str;
use Maatwebsite\Excel\Excel as ExcelWriter;
use Maatwebsite\Excel\Facades\Excel;
use Symfony\Component\HttpFoundation\BinaryFileResponse;
class ProductController extends Controller
{
@@ -40,6 +44,13 @@ class ProductController extends Controller
]);
}
public function export(): BinaryFileResponse
{
$filename = 'danh-sach-san-pham-' . now()->format('Y-m-d-His') . '.xlsx';
return Excel::download(new ProductsExport(), $filename, ExcelWriter::XLSX);
}
public function show(int $id): JsonResponse
{
$product = $this->products->findWithPricings($id);
@@ -0,0 +1,55 @@
<?php
namespace App\Http\Controllers\Api\Admin\Service;
use App\Http\Controllers\Controller;
use App\Models\Service;
use App\Services\ServiceRenewalReminderService;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Auth;
class ServiceRenewalReminderController extends Controller
{
public function __construct(private readonly ServiceRenewalReminderService $reminders) {}
public function index(Request $request): JsonResponse
{
$days = (int) $request->validate(['days' => 'required|integer|min:1|max:365'])['days'];
$items = $this->reminders->expiring($days)->map(fn (Service $service) => [
'id' => $service->id,
'server_name' => $service->server_name,
'product_name' => $service->product?->name,
'expires_at' => $service->expires_at,
'days_left' => $this->reminders->daysLeft($service),
'auto_renew' => (bool) $service->auto_renew,
'customer' => [
'uuid' => $service->customer->uuid,
'fullname' => $service->customer->fullname,
'email' => $service->customer->email,
],
]);
return response()->json([
'error' => false,
'data' => $items->values(),
'truncated' => $items->count() >= ServiceRenewalReminderService::MAX_RESULTS,
]);
}
public function send(Request $request): JsonResponse
{
$data = $request->validate([
'service_ids' => 'required|array|min:1|max:' . ServiceRenewalReminderService::MAX_RESULTS,
'service_ids.*' => 'integer',
]);
$count = $this->reminders->send($data['service_ids'], Auth::guard('admin')->user());
return response()->json([
'error' => false,
'message' => "Đã đưa {$count} email nhắc gia hạn vào hàng đợi gửi.",
]);
}
}
@@ -0,0 +1,57 @@
<?php
namespace App\Http\Requests\Admin\Hosting;
use Illuminate\Foundation\Http\FormRequest;
class ServicePlanRequest extends FormRequest
{
public function authorize(): bool
{
return true;
}
/**
* @return array<string, string>
*/
public function rules(): array
{
return [
'title' => 'required|string|max:255',
'description' => 'nullable|string|max:2000',
'vcpu' => 'required|integer|min:0',
'ram' => 'required|integer|min:0',
'disk' => 'required|integer|min:0',
'domain' => 'required|integer|min:0',
'bandwidth' => 'nullable|integer|min:0',
'databases' => 'nullable|integer|min:0',
'mailboxes' => 'nullable|integer|min:0',
'mailbox_quota' => 'nullable|integer|min:0',
'ftp_accounts' => 'nullable|integer|min:0',
'subdomains' => 'nullable|integer|min:0',
'domain_aliases' => 'nullable|integer|min:0',
'features' => 'nullable|array|max:30',
'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',
'plesk_plan_name' => 'nullable|string|max:255',
'note' => 'nullable|string',
];
}
/**
* @return array<string, mixed>
*/
public function planAttributes(): array
{
$validated = $this->validated();
return [
...collect($validated)->except(['plesk_plan_name', 'note'])->all(),
'dns_manager_server_id' => $validated['dns_manager_server_id'] ?? null,
'sort_order' => $validated['sort_order'] ?? 0,
'is_active' => $validated['is_active'] ?? true,
];
}
}
@@ -65,6 +65,17 @@ class OrderResource extends JsonResource
'reminder_step' => (int) $invoice->reminder_step,
'billing_period_start' => $invoice->billing_period_start,
'billing_period_end' => $invoice->billing_period_end,
'payment_method' => $invoice->payment?->name ?? $invoice->payment_method,
'note' => $invoice->note,
'attachments' => $invoice->relationLoaded('attachments')
? $invoice->attachments->map(fn($attachment) => [
'id' => $attachment->id,
'filename' => $attachment->filename,
'url' => $attachment->url,
'mime_type' => $attachment->mime_type,
'size' => $attachment->size,
])
: [],
// Which service(s)/product(s) this invoice actually bills for — without this,
// an invoice was a black box: just a total with no visible line items.
'items' => $invoice->relationLoaded('invoiceItems')
+15 -10
View File
@@ -39,6 +39,8 @@ class SendMailCampaignRecipient implements ShouldQueue
$customer = $this->customerId ? Customer::find($this->customerId) : null;
$rendered = $renderer->render($campaign, $renderer->recipientValues($campaign, $customer, $this->email));
$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']);
$failed = false;
try {
@@ -46,13 +48,13 @@ class SendMailCampaignRecipient implements ShouldQueue
if ($cc) {
$mailer->cc($cc);
}
if ($bcc = EmailRecipients::parse($campaign->bcc)) {
if ($bcc) {
$mailer->bcc($bcc);
}
$failed = $mailer->send(new GenericTemplateMail(
$rendered['subject'],
$rendered['html'],
Arr::only($campaign->template?->mailConfig() ?? [], ['from_email', 'from_name', 'reply_to', 'reply_to_name']),
$mailConfig,
)) === null;
} catch (\Throwable $e) {
$failed = true;
@@ -64,14 +66,17 @@ class SendMailCampaignRecipient implements ShouldQueue
}
MailLog::create([
'campaign_id' => $campaign->id,
'template_id' => $campaign->template_id ?? 0,
'subject' => $rendered['subject'],
'to' => $this->email,
'cc' => implode(', ', $cc),
'customer_id' => $customer?->id,
'message' => $rendered['html'],
'status' => $failed ? 'failed' : 'sent',
'campaign_id' => $campaign->id,
'template_id' => $campaign->template_id ?? 0,
'subject' => $rendered['subject'],
'from_address' => $mailConfig['from_email'] ?? config('mail.from.address'),
'to' => $this->email,
'cc' => implode(', ', $cc),
'bcc' => implode(', ', $bcc),
'customer_id' => $customer?->id,
'sent_by' => $campaign->created_by,
'message' => $rendered['html'],
'status' => $failed ? 'failed' : 'sent',
]);
$this->markDone($campaign, $failed);
+1 -5
View File
@@ -2,7 +2,6 @@
namespace App\Listeners\Mail;
use App\Models\Setting;
use App\Support\AutoEmailGate;
use Illuminate\Mail\Events\MessageSending;
use Illuminate\Support\Facades\Log;
@@ -17,10 +16,7 @@ class CheckMailEnabled
*/
public function handle(MessageSending $event): bool
{
$setting = Setting::where('key', 'email_settings')->first();
$config = $setting ? json_decode($setting->value, true) : [];
if (array_key_exists('mail_enabled', $config) && !$config['mail_enabled']) {
if (!AutoEmailGate::mailEnabled()) {
Log::info('Mail sending blocked: mail_enabled is off.', [
'subject' => $event->message->getSubject(),
]);
+5 -1
View File
@@ -3,6 +3,7 @@
namespace App\Mail\Customer;
use App\Models\Template;
use App\Support\EmailTemplateShell;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Mail\Mailable;
@@ -17,6 +18,7 @@ class ManualCustomerEmail extends Mailable implements ShouldQueue
public function __construct(
private readonly string $emailSubject,
private readonly string $htmlContent,
private readonly bool $wrapLayout = true,
) {}
public function envelope(): Envelope
@@ -27,7 +29,9 @@ class ManualCustomerEmail extends Mailable implements ShouldQueue
public function content(): Content
{
return new Content(
html: Template::renderWithLayout($this->emailSubject, $this->htmlContent),
htmlString: $this->wrapLayout
? Template::renderWithLayout($this->emailSubject, $this->htmlContent)
: EmailTemplateShell::document($this->emailSubject, $this->htmlContent),
);
}
+8
View File
@@ -15,9 +15,12 @@ class Mail extends Model
'campaign_id',
'template_id',
'subject',
'from_address',
'to',
'cc',
'bcc',
'customer_id',
'sent_by',
'message',
'status'
];
@@ -44,6 +47,11 @@ class Mail extends Model
return $this->belongsTo(Customer::class, 'customer_id');
}
public function sender()
{
return $this->belongsTo(User::class, 'sent_by');
}
public function template()
{
return $this->belongsTo(Template::class, 'template_id');
+1 -1
View File
@@ -69,7 +69,7 @@ class Order extends Model
public function payment()
{
return $this->belongsTo(Payment::class);
return $this->belongsTo(Payment::class, 'payment_method', 'id');
}
public function moduleVmwares()
+16
View File
@@ -8,13 +8,29 @@ class ServicePlan extends Model
{
protected $fillable = [
'title',
'description',
'vcpu',
'ram',
'disk',
'domain',
'bandwidth',
'databases',
'mailboxes',
'mailbox_quota',
'ftp_accounts',
'subdomains',
'domain_aliases',
'features',
'is_active',
'sort_order',
'dns_manager_server_id',
];
protected $casts = [
'features' => 'array',
'is_active' => 'boolean',
];
public function plesk()
{
return $this->hasOne(ServicePlanPlesk::class);
+6 -3
View File
@@ -108,14 +108,17 @@ class Template extends Model
* dùng khung header/footer chuẩn trong code thay vì gửi ra nội dung trần.
*/
public static function renderWithLayout(?string $subject, string $contentHtml): string
{
return EmailTemplateShell::document($subject, self::layoutBody($contentHtml));
}
public static function layoutBody(string $contentHtml): string
{
$layout = self::where('scenario', 'layout')->where('status', 1)->first();
$body = $layout && $layout->message
return $layout && $layout->message
? str_replace('{{$content}}', $contentHtml, $layout->message)
: EmailTemplateShell::wrap(EmailTemplateShell::body($contentHtml));
return EmailTemplateShell::document($subject, $body);
}
public function type()
+1 -1
View File
@@ -64,7 +64,7 @@ class User extends Authenticatable implements JWTSubject
public function getNameAttribute(): string
{
return trim((string) $this->fullname);
return trim("{$this->firstname} {$this->lastname}") ?: trim((string) $this->fullname);
}
public function staffOnline()
@@ -152,6 +152,8 @@ class OrderRepository extends BaseRepository implements OrderRepositoryInterface
'payment',
'discount',
'invoices.invoiceItems.product',
'invoices.attachments',
'invoices.payment',
])->where('order_num', $orderNum)->first();
}
}
@@ -274,9 +274,12 @@ class DomainOrderService
throw new \InvalidArgumentException('Chỉ có thể kích hoạt tên miền sau khi đã tạo tại nhà đăng ký (hoặc sau khi KYC).');
}
$startedAt = $service->started_at ?? now();
$service->update([
'status' => DomainProvisioningStatus::ACTIVE->value,
'started_at' => $service->started_at ?? now(),
'started_at' => $startedAt,
'expires_at' => $service->expires_at ?? $startedAt->copy()->addMonths((int) $orderItem->months),
]);
$order->update(['status' => OrderStatus::ACTIVE->value]);
+74 -9
View File
@@ -2,8 +2,9 @@
namespace App\Services\Customer;
use App\Support\EmailRecipients;
use App\Exceptions\InvalidTemplateVariableException;
use App\Mail\Customer\ManualCustomerEmail;
use App\Mail\GenericTemplateMail;
use App\Mail\Customer\PasswordResetByAdmin;
use App\Models\Customer;
use App\Models\Log;
@@ -12,6 +13,11 @@ use App\Models\Template;
use App\Models\User;
use App\Repositories\Customer\CustomerRepositoryInterface;
use App\Services\Customer\Concerns\ResolvesCustomer;
use App\Services\Email\EmailTemplateRenderer;
use App\Support\AutoEmailGate;
use App\Support\EmailRecipients;
use App\Support\MailGlobals;
use App\Support\MailPlaceholders;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Hash;
use Illuminate\Support\Facades\Mail;
@@ -22,8 +28,62 @@ class CustomerMailService
public function __construct(
private readonly CustomerRepositoryInterface $customerRepository,
private readonly EmailTemplateRenderer $renderer,
) {}
/**
* @return array{subject: string, html: string, cc: array<int, string>, bcc: array<int, string>}
*/
public function previewTemplateEmail(string $uuid, int $templateId): array
{
$customer = $this->resolveCustomer($uuid);
$template = $this->sendableTemplate($templateId);
$config = $template->mailConfig();
return [
...$this->renderForCustomer($template, $customer),
'cc' => $config['cc'] ?? [],
'bcc' => $config['bcc'] ?? [],
];
}
public function wrapWithLayout(string $uuid, string $contentHtml): string
{
$customer = $this->resolveCustomer($uuid);
return MailPlaceholders::fill(Template::layoutBody($contentHtml), [
...MailGlobals::values(),
...MailPlaceholders::recipientValues($customer, $customer->email),
]);
}
private function sendableTemplate(int $templateId): Template
{
$template = Template::query()->whereKey($templateId)->where('status', 1)->first();
abort_if(!$template, 422, 'Template không tồn tại hoặc đang bị tắt.');
abort_if($template->format === 'json', 422, 'Không thể gửi template JSON (hoá đơn).');
return $template;
}
/**
* @return array{subject: string, html: string}
*/
private function renderForCustomer(Template $template, Customer $customer): array
{
$values = collect($template->variables())
->map(fn ($definition) => $definition['example'] ?? '')
->merge(MailGlobals::values())
->merge(MailPlaceholders::recipientValues($customer, $customer->email))
->all();
try {
return $this->renderer->render($template, $values);
} catch (InvalidTemplateVariableException $e) {
abort(422, $e->getMessage());
}
}
/**
* Reset về mật khẩu ngẫu nhiên và gửi mail cho khách hàng.
*/
@@ -80,16 +140,16 @@ class CustomerMailService
{
$customer = $this->resolveCustomer($uuid);
$template = null;
abort_if(!AutoEmailGate::mailEnabled(), 422, 'Hệ thống đang tắt gửi email (Mẫu email → Cài đặt → Bật gửi email).');
$template = empty($data['template_id']) ? null : $this->sendableTemplate((int) $data['template_id']);
$subject = trim((string) ($data['subject'] ?? ''));
$message = trim((string) ($data['message'] ?? ''));
if (!empty($data['template_id'])) {
$template = Template::query()->whereKey($data['template_id'])->where('status', 1)->first();
abort_if(!$template, 422, 'Template không tồn tại hoặc đang bị tắt.');
$subject = trim((string) $template->subject);
$message = trim((string) $template->message);
if ($template && ($subject === '' || $message === '')) {
$rendered = $this->renderForCustomer($template, $customer);
$subject = $subject ?: $rendered['subject'];
$message = $message ?: $rendered['html'];
}
abort_if($subject === '' || $message === '', 422, 'Subject và content không được để trống.');
@@ -107,14 +167,19 @@ class CustomerMailService
if ($bcc !== []) {
$mailer->bcc($bcc);
}
$mailer->send(new ManualCustomerEmail($subject, $message));
$mailer->send($template
? new GenericTemplateMail($subject, $message, array_diff_key($template->mailConfig(), array_flip(['cc', 'bcc'])))
: new ManualCustomerEmail($subject, $message, wrapLayout: (bool) ($data['with_layout'] ?? true)));
MailLog::create([
'template_id' => $template->id ?? 0,
'subject' => $subject,
'from_address' => $template?->mailConfig()['from_email'] ?? config('mail.from.address'),
'to' => implode(', ', $to),
'cc' => implode(', ', $cc),
'bcc' => implode(', ', $bcc),
'customer_id' => $customer->id,
'sent_by' => $admin->id,
'message' => $message,
]);
@@ -82,7 +82,7 @@ class CustomerServiceProvisioningService
return DB::transaction(function () use ($customer, $service, $data) {
$vmFields = ['virtual_machine_id', 'os_id', 'vm_cpu', 'vm_ram_mb', 'vm_disk_gb', 'vm_ip', 'vm_dns', 'vm_user', 'vm_password', 'vm_port'];
$service->fill(collect($data)->except(['price', ...$vmFields])->all());
$service->fill(collect($data)->except(['price', 'billing_cycle', ...$vmFields])->all());
$service->save();
if (!empty($data['virtual_machine_id'])) {
@@ -93,6 +93,13 @@ class CustomerServiceProvisioningService
$service->orderItem->update(['price' => $data['price']]);
}
if (!empty($data['billing_cycle']) && $service->orderItem) {
$service->orderItem->update([
'billing_cycle' => $data['billing_cycle'],
'months' => OrderHelper::cycleToMonths($data['billing_cycle']),
]);
}
// Đồng bộ sang module_vmware nếu có gán VM
if ($service->vm_id) {
$vmData = array_filter([
+10 -3
View File
@@ -145,12 +145,13 @@ class InvoicePaymentService
?int $adminId = null,
string $method = 'admin',
?int $receivedAmount = null,
string $note = '',
): Invoice {
if ((int) $invoice->status === InvoiceStatus::PAID->value) {
return $invoice->fresh(['order.orderItems.product', 'services', 'customer']);
}
return DB::transaction(function () use ($invoice, $adminId, $method, $receivedAmount) {
return DB::transaction(function () use ($invoice, $adminId, $method, $receivedAmount, $note) {
$invoice = Invoice::where('id', $invoice->id)->lockForUpdate()->firstOrFail();
if ((int) $invoice->status === InvoiceStatus::PAID->value) {
@@ -174,13 +175,19 @@ class InvoicePaymentService
'status' => $settled ? InvoiceStatus::PAID->value : $invoice->status,
'paid_at' => $settled ? now() : null,
'payment_method' => $invoice->payment_method ?: $method,
'note' => $note !== '' ? $note : $invoice->note,
]);
$transactionNote = 'Admin xác nhận thanh toán hóa đơn #' . $invoice->invoice_number
. ($settled ? '' : sprintf(' (một phần: %s/%s VNĐ)', number_format($paidSoFar), number_format((int) $invoice->total)));
if ($note !== '') {
$transactionNote .= '. Ghi chú: ' . $note;
}
$this->recordTransaction(
$invoice,
$method,
'Admin xác nhận thanh toán hóa đơn #' . $invoice->invoice_number
. ($settled ? '' : sprintf(' (một phần: %s/%s VNĐ)', number_format($paidSoFar), number_format((int) $invoice->total))),
$transactionNote,
$adminId,
amount: $received,
sequence: $settled && $received === (int) $invoice->total ? null : $paidSoFar,
+23 -5
View File
@@ -13,6 +13,7 @@ use App\Jobs\Mail\Customer\OrderCreatedJob;
use App\Jobs\VirtualMachine\Create\CreateVirtualMachineJob;
use App\Jobs\VirtualMachine\Upgrade\UpgradeVirtualMachine;
use App\Models\Customer;
use App\Models\DomainPricing;
use App\Models\Order;
use App\Models\OrderItem;
use App\Models\Payment;
@@ -155,11 +156,15 @@ class OrderService
$orderData = [
'order_num' => Helper::generateUniqueCode(),
'date' => Carbon::today()->toDateString(),
'customer_id' => $data['customer_id'],
'discount_id' => $data['discount_id'] ?? null,
'sub_total' => $total,
'total' => $orderTotal,
'payment_method' => $paymentRecord?->code ?? null,
// `payment` has no `code` column. Store its primary key in the
// legacy string column so the selected method can be resolved
// consistently on both orders and invoices.
'payment_method' => $paymentRecord?->id,
'payment_proof' => $data['payment_proof'] ?? null,
'user_id' => Auth::guard('admin')->id(),
'status' => $effectiveOrderStatus,
@@ -287,7 +292,20 @@ class OrderService
};
$billingDetails = $this->calculateBillingDetails($item['billing']);
$priceSelect = $item['price_custom'] ?? $product->price_sale ?? $product->price;
$isDomain = $categoryType === ProductTypeEnums::DOMAIN;
$billingMultiplier = $isDomain ? 1 : $billingDetails['billing'];
if ($isDomain) {
$domainName = strtolower(trim((string) ($item['server_name'] ?? $item['domain'] ?? '')));
if ($domainName === '') {
throw new \InvalidArgumentException('Vui lòng nhập đầy đủ tên miền.');
}
$domainPrice = DomainPricing::findByDomain($domainName)->orderPrice(1);
$priceSelect = $item['price_custom'] ?? $domainPrice;
} else {
$priceSelect = $item['price_custom'] ?? $product->price_sale ?? $product->price;
}
$billingCycle = ProductBillingCycle::fromLegacyCode((int) $item['billing']);
$generalCode = Helper::generateUniqueCode();
@@ -312,8 +330,8 @@ class OrderService
'customer_id' => $data['customer_id'],
'status' => 0,
'name' => $item['server_name'] ?? $item['domain'] ?? $product->name,
'first_payment' => $priceSelect * $billingDetails['billing'],
'next_payment' => ($item['price_custom'] ?? $product->price) * $billingDetails['billing'],
'first_payment' => $priceSelect * $billingMultiplier,
'next_payment' => $priceSelect * $billingMultiplier,
...$billingDetails,
],
];
@@ -326,7 +344,7 @@ class OrderService
}
}
$total += $item['quantity'] * ($item['price_custom'] ?? ($product->price_sale ?? $product->price)) * $billingDetails['billing'];
$total += $item['quantity'] * $priceSelect * $billingMultiplier;
}
return [$productItems, $relIdData, $total];
+1
View File
@@ -13,6 +13,7 @@ class PleskService
protected array $entryPoints = [
'clients' => ':8443/api/v2/clients',
'domains' => ':8443/api/v2/domains',
'cli' => ':8443/api/v2/cli',
];
public function client(DnsServer $server, int $timeout = 30): PendingRequest
@@ -0,0 +1,39 @@
<?php
namespace App\Services\Plesk;
use App\Models\DnsServer;
use Illuminate\Http\Client\ConnectionException;
use Illuminate\Support\Facades\Log;
class PleskServicePlanService
{
public function __construct(private PleskService $plesk)
{
}
/**
* @return list<string>|null
*/
public function names(DnsServer $server): ?array
{
try {
$response = $this->plesk->client($server, 15)
->post($this->plesk->url($server, 'cli', '/service_plan/call'), ['params' => ['--list']]);
} catch (ConnectionException $e) {
Log::error('Plesk service plan list failed', ['error' => $e->getMessage(), 'server_id' => $server->id]);
return null;
}
if (!$response->successful() || (int) $response->json('code', 1) !== 0) {
Log::error('Plesk service plan list failed', ['status' => $response->status(), 'server_id' => $server->id]);
return null;
}
$lines = preg_split('/\R/', (string) $response->json('stdout', ''));
return array_values(array_unique(array_filter(array_map('trim', $lines), 'strlen')));
}
}
@@ -0,0 +1,91 @@
<?php
namespace App\Services\Product;
use App\Models\ProductCategories;
use Illuminate\Support\Facades\DB;
final class ProductCategorySampleService
{
/**
* Fill missing metadata on known groups and create missing sample groups.
* Existing administrator-entered values are never overwritten.
*
* @return array{created: int, updated: int}
*/
public function seed(): array
{
$definitions = [
['slug' => 'vps', 'name' => 'VPS', 'type' => 'vps', 'sort_order' => 10, 'billing_cycles' => $this->recurringCycles()],
['slug' => 'hosting', 'name' => 'Hosting', 'type' => 'hosting', 'sort_order' => 20, 'billing_cycles' => $this->recurringCycles()],
['slug' => 'cloud-storage', 'name' => 'Cloud Storage', 'type' => 'cloud', 'sort_order' => 30, 'billing_cycles' => $this->recurringCycles()],
['slug' => 'email', 'name' => 'Email', 'type' => 'email', 'sort_order' => 40, 'billing_cycles' => $this->recurringCycles()],
['slug' => 'hardware', 'name' => 'Hardware', 'type' => 'hardware', 'sort_order' => 50, 'billing_cycles' => ['one_time']],
['slug' => 'thiet-ke-website', 'name' => 'Thiết kế website', 'type' => 'web_design', 'sort_order' => 60, 'billing_cycles' => ['one_time']],
['slug' => 'it-support', 'name' => 'IT Support', 'type' => 'it_support', 'sort_order' => 70, 'billing_cycles' => ['monthly', 'annually', 'one_time']],
['slug' => 'domain', 'name' => 'Tên miền Việt Nam', 'type' => 'domain', 'sort_order' => 75, 'billing_cycles' => ['annually']],
['slug' => 'other', 'name' => 'Other', 'type' => 'addon', 'sort_order' => 80, 'billing_cycles' => ['one_time']],
];
return DB::transaction(function () use ($definitions): array {
$created = 0;
$updated = 0;
foreach ($definitions as $definition) {
$group = ProductCategories::withTrashed()->firstOrNew(['slug' => $definition['slug']]);
$isNew = ! $group->exists;
if ($isNew) {
$group->fill([
...$definition,
'parent_id' => null,
'is_active' => true,
]);
} else {
foreach (['type', 'billing_cycles'] as $field) {
if (blank($group->{$field})) {
$group->{$field} = $definition[$field];
}
}
}
if ($isNew || $group->isDirty()) {
$group->save();
$isNew ? $created++ : $updated++;
}
if ($group->trashed()) {
$group->restore();
$updated++;
}
}
ProductCategories::query()
->whereNotNull('type')
->whereNull('billing_cycles')
->get()
->each(function (ProductCategories $group) use (&$updated): void {
$group->billing_cycles = $this->cyclesForType($group->type);
$group->save();
$updated++;
});
return compact('created', 'updated');
});
}
private function cyclesForType(string $type): array
{
return match ($type) {
'hardware', 'web_design', 'addon' => ['one_time'],
'domain' => ['annually'],
'it_support' => ['monthly', 'annually', 'one_time'],
default => $this->recurringCycles(),
};
}
private function recurringCycles(): array
{
return ['monthly', 'quarterly', 'semi_annually', 'annually', 'biennially', 'triennially'];
}
}
@@ -0,0 +1,81 @@
<?php
namespace App\Services;
use App\Mail\ServiceRenewalReminderMail;
use App\Models\Mail as MailLog;
use App\Models\Service;
use App\Models\User;
use App\Services\Email\EmailTemplateService;
use App\Support\AutoEmailGate;
use Illuminate\Database\Eloquent\Collection;
use Illuminate\Database\Eloquent\ModelNotFoundException;
use Illuminate\Support\Facades\Mail;
class ServiceRenewalReminderService
{
private const SCENARIO = 'service_renewal_reminder';
public const MAX_RESULTS = 1000;
public function __construct(private readonly EmailTemplateService $templates) {}
/**
* @return Collection<int, Service>
*/
public function expiring(int $days): Collection
{
return Service::with(['customer:id,uuid,fullname,email', 'product:id,name'])
->where('status', 'active')
->whereHas('customer')
->whereBetween('expires_at', [now()->startOfDay(), now()->addDays($days)->endOfDay()])
->orderBy('expires_at')
->limit(self::MAX_RESULTS)
->get();
}
public function daysLeft(Service $service): int
{
return (int) now()->startOfDay()->diffInDays($service->expires_at->copy()->startOfDay(), false);
}
/**
* @param array<int, int> $serviceIds
*/
public function send(array $serviceIds, User $admin): int
{
abort_if(!AutoEmailGate::mailEnabled(), 422, 'Hệ thống đang tắt gửi email (Mẫu email → Cài đặt → Bật gửi email).');
try {
$template = $this->templates->loadTemplate(self::SCENARIO);
} catch (ModelNotFoundException) {
abort(422, 'Chưa có template nhắc gia hạn (service_renewal_reminder) đang bật trong Mẫu email.');
}
AutoEmailGate::markManual();
$services = Service::with(['customer', 'product', 'orderItem'])
->whereIn('id', $serviceIds)
->whereNotNull('expires_at')
->get()
->filter(fn (Service $service) => filled($service->customer?->email));
foreach ($services as $service) {
$mail = new ServiceRenewalReminderMail($service->customer, $service);
MailLog::create([
'template_id' => $template->id,
'subject' => $mail->envelope()->subject,
'from_address' => $template->mailConfig()['from_email'] ?? config('mail.from.address'),
'to' => $service->customer->email,
'customer_id' => $service->customer_id,
'sent_by' => $admin->id,
'message' => $mail->render(),
]);
Mail::to($service->customer->email)->queue($mail);
}
return $services->count();
}
}
+11 -1
View File
@@ -25,13 +25,23 @@ final class AutoEmailGate
return in_array($scenario, self::disabledScenarios(), true);
}
public static function mailEnabled(): bool
{
return (bool) (self::emailSettings()['mail_enabled'] ?? true);
}
/**
* @return array<int, string>
*/
public static function disabledScenarios(): array
{
return (array) (self::emailSettings()['auto_disabled'] ?? []);
}
private static function emailSettings(): array
{
$value = Setting::where('key', 'email_settings')->value('value');
return (array) (json_decode($value ?? '', true)['auto_disabled'] ?? []);
return (array) (json_decode($value ?? '', true) ?? []);
}
}
+1 -1
View File
@@ -124,7 +124,7 @@ final class EmailScenarioRegistry
'required_variables' => ['domain_name', 'order_num'],
],
'domain_order_approved' => [
'label' => 'Tên miền được duyệt', 'category' => self::BUSINESS, 'group' => 'ORDER',
'label' => 'Bàn giao tên miền (đã duyệt)', 'category' => self::BUSINESS, 'group' => 'ORDER',
'required_variables' => ['domain_name'], 'permissions' => ['disable' => true],
],
'domain_order_rejected' => [
@@ -0,0 +1,25 @@
<?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('mail', function (Blueprint $table) {
$table->string('from_address', 255)->nullable()->after('subject');
$table->string('bcc', 1000)->nullable()->after('cc');
$table->unsignedBigInteger('sent_by')->nullable()->index()->after('customer_id');
});
}
public function down(): void
{
Schema::table('mail', function (Blueprint $table) {
$table->dropIndex(['sent_by']);
$table->dropColumn(['from_address', 'bcc', 'sent_by']);
});
}
};
@@ -0,0 +1,35 @@
<?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('service_plans', function (Blueprint $table) {
$table->text('description')->nullable()->after('title');
$table->unsignedInteger('bandwidth')->nullable()->after('domain');
$table->unsignedInteger('databases')->nullable()->after('bandwidth');
$table->unsignedInteger('mailboxes')->nullable()->after('databases');
$table->unsignedInteger('mailbox_quota')->nullable()->after('mailboxes');
$table->unsignedInteger('ftp_accounts')->nullable()->after('mailbox_quota');
$table->unsignedInteger('subdomains')->nullable()->after('ftp_accounts');
$table->unsignedInteger('domain_aliases')->nullable()->after('subdomains');
$table->json('features')->nullable()->after('domain_aliases');
$table->boolean('is_active')->default(true)->after('features');
$table->unsignedInteger('sort_order')->default(0)->after('is_active');
});
}
public function down(): void
{
Schema::table('service_plans', function (Blueprint $table) {
$table->dropColumn([
'description', 'bandwidth', 'databases', 'mailboxes', 'mailbox_quota',
'ftp_accounts', 'subdomains', 'domain_aliases', 'features', 'is_active', 'sort_order',
]);
});
}
};
@@ -0,0 +1,28 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Support\Facades\DB;
return new class extends Migration
{
public function up(): void
{
DB::table('orders')
->whereNull('payment_method')
->whereExists(function ($query) {
$query->selectRaw('1')
->from('invoices')
->whereColumn('invoices.order_id', 'orders.id')
->whereNotNull('invoices.payment_method');
})
->update([
'payment_method' => DB::raw('(SELECT invoices.payment_method FROM invoices WHERE invoices.order_id = orders.id AND invoices.payment_method IS NOT NULL ORDER BY invoices.id ASC LIMIT 1)'),
]);
}
public function down(): void
{
// Preserve recovered payment data. The application remains compatible
// because orders.payment_method is still the original nullable string.
}
};
@@ -0,0 +1,21 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Support\Facades\DB;
return new class extends Migration
{
public function up(): void
{
DB::table('orders')
->whereNull('date')
->whereNotNull('created_at')
->update(['date' => DB::raw('DATE(created_at)')]);
}
public function down(): void
{
// Preserve recovered order dates. The date column remains nullable and
// compatible with older application versions after a code rollback.
}
};
@@ -0,0 +1,46 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Support\Facades\DB;
return new class extends Migration
{
private const TYPES = ['vps', 'cloud'];
private const FIELDS = [
['key' => 'cpu', 'label' => 'CPU (vCPU)', 'sort_order' => 1],
['key' => 'ram', 'label' => 'RAM (GB)', 'sort_order' => 2],
['key' => 'disk', 'label' => 'Disk (GB)', 'sort_order' => 3],
['key' => 'bandwidth', 'label' => 'Băng thông (GB)', 'sort_order' => 4],
];
public function up(): void
{
$now = now();
foreach (self::TYPES as $type) {
foreach (self::FIELDS as $field) {
$exists = DB::table('product_type_fields')
->where('product_type', $type)
->where('key', $field['key'])
->exists();
if ($exists) {
continue;
}
DB::table('product_type_fields')->insert($field + [
'product_type' => $type,
'field_type' => 'number',
'is_required' => false,
'created_at' => $now,
'updated_at' => $now,
]);
}
}
}
public function down(): void
{
}
};
@@ -0,0 +1,137 @@
<?php
namespace Database\Seeders;
use App\Models\Customer;
use App\Models\Mail;
use App\Models\Product;
use App\Models\Service;
use App\Models\Setting as SettingModel;
use App\Models\Template;
use App\Models\User;
use App\Support\EmailTemplateShell as Shell;
use Database\Seeders\EmailTemplates\MarketingTemplates;
use Illuminate\Database\Seeder;
use Illuminate\Support\Facades\Hash;
use Illuminate\Support\Str;
/**
* Dữ liệu thử các luồng gửi email của admin (gửi 1 khách, email marketing, nhắc gia hạn, tab Email).
* Chạy trên DB trống: php artisan migrate:fresh --seed --seeder=EmailScenarioDemoSeeder
*/
class EmailScenarioDemoSeeder extends Seeder
{
private const CUSTOMER_PASSWORD = '!admin@123!@#';
private const CUSTOMERS = [
'pbao.dev@gmail.com' => ['Phạm Bảo (dev)', '0900000001', [['vps', 2], ['email', 25], ['vps', -4]]],
'pbao.business@gmail.com' => ['Phạm Bảo Business', '0900000002', [['email', 5], ['server', 45]]],
'phamgiabao48756@gmail.com' => ['Phạm Gia Bảo', '0900000003', [['website', 12], ['vps', 75]]],
'pbao.drive@gmail.com' => ['Phạm Bảo Drive', '0900000004', [['server', 0], ['support', 150], ['email', 9, 'suspended']]],
];
private const PRODUCT_KEYWORDS = [
'vps' => 'VPS',
'email' => 'Email',
'server' => 'Máy Chủ',
'website' => 'Website',
'support' => 'IT Support',
];
public function run(): void
{
$this->call([
RoleSeeder::class,
RolesAndPermissions::class,
TestAccountSeeder::class,
TemplateSeeder::class,
Setting::class,
ProductCategorySeeder::class,
ProductTypeFieldSeeder::class,
ProductMockupSeeder::class,
VPSProductSeeder::class,
]);
$this->enableMail();
$marketing = Template::whereIn('name', array_column(MarketingTemplates::all(), 'name'))->orderBy('id')->get();
$admin = User::where('username', 'admin')->firstOrFail();
foreach (self::CUSTOMERS as $email => [$name, $phone, $services]) {
$customer = $this->seedCustomer($email, $name, $phone);
foreach ($services as $service) {
$this->seedService($customer, ...$service);
}
$this->seedMailHistory($customer, $admin, $marketing->first());
}
$this->command?->info('Đã tạo ' . count(self::CUSTOMERS) . ' khách hàng, ' . count($marketing) . ' mẫu marketing và dịch vụ sắp hết hạn.');
}
private function enableMail(): void
{
$setting = SettingModel::firstOrNew(['key' => 'email_settings']);
$value = json_decode($setting->value ?? '{}', true) ?: [];
$setting->value = json_encode([...$value, 'mail_enabled' => true], JSON_UNESCAPED_UNICODE);
$setting->save();
}
private function seedCustomer(string $email, string $name, string $phone): Customer
{
$customer = Customer::firstOrNew(['email' => $email]);
$customer->fill([
'fullname' => $name,
'username' => Str::before($email, '@'),
'phone' => $phone,
'password' => Hash::make(self::CUSTOMER_PASSWORD),
'email_verified_at' => now(),
'email_verify' => 1,
]);
$customer->forceFill(['status' => 1])->save();
Service::where('customer_id', $customer->id)->where('note', 'seed:email-demo')->delete();
return $customer;
}
private function seedService(Customer $customer, string $kind, int $daysLeft, string $status = 'active'): void
{
$product = Product::where('name', 'like', '%' . self::PRODUCT_KEYWORDS[$kind] . '%')->first() ?? Product::first();
Service::create([
'uuid' => (string) Str::uuid(),
'customer_id' => $customer->id,
'product_id' => $product?->id,
'server_name' => Str::upper($kind) . '-' . Str::before($customer->email, '@'),
'status' => $status,
'auto_renew' => $daysLeft > 30,
'started_at' => now()->addDays($daysLeft)->subYear(),
'expires_at' => now()->addDays($daysLeft)->setTime(17, 0),
'note' => 'seed:email-demo',
]);
}
private function seedMailHistory(Customer $customer, User $admin, Template $template): void
{
Mail::where('customer_id', $customer->id)->delete();
Mail::create([
'template_id' => $template->id,
'subject' => str_replace('{{client_name}}', $customer->fullname, $template->subject),
'from_address' => config('mail.from.address'),
'to' => $customer->email,
'bcc' => 'audit@tgs.com.vn',
'customer_id' => $customer->id,
'sent_by' => $admin->id,
'message' => str_replace(['{{client_name}}', '{{promo_code}}', '{{frontend_url}}'], [$customer->fullname, 'TGS30', '#'], $template->message),
]);
Mail::create([
'subject' => 'Chào mừng ' . $customer->fullname . ' đến với Thế Giới Số',
'to' => $customer->email,
'customer_id' => $customer->id,
'message' => Template::renderWithLayout('Chào mừng', Shell::paragraph('Tài khoản của Quý khách đã được kích hoạt.')),
]);
}
}
@@ -116,18 +116,31 @@ final class BillingTemplates
],
[
'scenario' => 'domain_order_approved',
'name' => 'Tên miền được duyệt',
'subject' => 'Tên miền {{domain_name}} đã được duyệt đăng ký',
'body' => T::title('Tên miền đã được duyệt')
'name' => 'Bàn giao tên miền',
'subject' => 'Bàn giao tên miền {{domain_name}} - {{company_name}}',
'body' => T::title('Bàn giao tên miền')
. T::greeting('{{client_name}}')
. T::paragraph('Đơn đăng ký tên miền của Quý khách đã được <strong style="color: #16a34a;">duyệt và kích hoạt thành công</strong>.')
. T::paragraph('Công ty Cổ phần Giải pháp Công nghệ Thế Giới Số xin chân thành cảm ơn Quý khách đã tin tưởng lựa chọn dịch vụ đăng ký và quản lý tên miền của chúng tôi.')
. T::heading('🌐 Thông tin tên miền')
. T::infoTable([
'Tên miền' => '{{domain_name}}',
'Mã đơn hàng' => '{{order_num}}',
'Tên miền' => '{{domain_name}}',
'Mã đơn hàng' => '{{order_num}}',
'Ngày đăng ký' => '{{registered_at}}',
'Ngày hết hạn' => '{{expires_at}}',
'NS1' => '{{name_server_1}}',
'NS2' => '{{name_server_2}}',
])
. T::paragraph('Tên miền hiện đã hoạt động.')
. T::heading('🔐 Thông tin tài khoản quản lý')
. T::infoTable([
'Trang đăng nhập' => '<a href="{{login_url}}" target="_blank" rel="noopener">{{login_url}}</a>',
'Tên đăng nhập' => '{{customer_email}}',
'Mật khẩu' => 'Mật khẩu tài khoản portal của Quý khách',
])
. T::note('Quý khách vui lòng bảo mật thông tin tài khoản. Nếu quên mật khẩu, sử dụng chức năng "Quên mật khẩu" tại trang đăng nhập.')
. '{{kyc_block}}'
. T::heading('📞 Hỗ trợ kỹ thuật 24/7')
. T::supportLine()
. T::autoNotice(),
. T::paragraph('Một lần nữa, Thế Giới Số xin chân thành cảm ơn sự tin tưởng và đồng hành của Quý khách.'),
],
[
'scenario' => 'domain_order_rejected',
@@ -0,0 +1,74 @@
<?php
namespace Database\Seeders\EmailTemplates;
use App\Support\EmailTemplateShell as T;
final class LegacyTemplates
{
/**
* @return array<int, array{scenario: string, name: string, subject: string, body: string}>
*/
public static function all(): array
{
return [
[
'scenario' => 'customer_signup_verify',
'name' => '[Admin] Đơn hàng mới đã thanh toán',
'subject' => '[ADMIN] Đơn hàng mới #{{order_num}} — {{customer_email}}',
'body' => T::title('📦 Đơn hàng mới đã thanh toán')
. T::subtitle('Đơn #{{order_num}} — {{created_at}}')
. T::heading('Thông tin khách hàng')
. T::infoTable([
'Tên khách hàng' => '{{customer_name}}',
'Email' => '{{customer_email}}',
], '{{client_ip_row}}')
. T::heading('Thông tin đơn hàng')
. T::infoTable([
'Mã đơn hàng' => '#{{order_num}}',
'Sản phẩm' => '{{product_name}}',
'Loại dịch vụ' => '{{product_type}}',
'Chu kỳ thanh toán' => '{{billing_cycle}}',
'Tổng tiền' => '{{total_formatted}}',
'Trạng thái' => '<span style="color: #16a34a;">✅ Đã thanh toán</span>',
])
. '{{vm_block}}'
. T::autoNotice(),
],
[
'scenario' => 'customer_password_reset',
'name' => '[Admin] Yêu cầu nạp tiền chờ duyệt (kèm chứng từ)',
'subject' => '[ADMIN] Yêu cầu nạp tiền chờ duyệt - {{transaction_code}}',
'body' => T::title('🔔 Yêu cầu nạp tiền chờ duyệt')
. T::subtitle('{{transaction_code}} — {{created_at}}')
. T::heading('Thông tin khách hàng')
. T::infoTable([
'Tên khách hàng' => '{{customer_name}}',
'Email' => '{{customer_email}}',
], '{{customer_phone_row}}{{client_ip_row}}')
. T::heading('Thông tin giao dịch')
. T::infoTable([
'Mã giao dịch' => '{{transaction_code}}',
'Số tiền' => '{{amount_formatted}}',
'Trạng thái' => '<span style="color: #d97706;">Đang chờ duyệt</span>',
], '{{proof_row}}')
. T::note('Vui lòng vào trang quản trị để kiểm tra chứng từ và duyệt hoặc từ chối giao dịch.'),
],
[
'scenario' => 'vm_password_reset_success',
'name' => 'Mật khẩu VPS đã được đặt lại',
'subject' => 'Mật khẩu VPS của bạn đã được đặt lại',
'body' => T::title('🔑 Mật khẩu VPS đã được đặt lại')
. T::subtitle('Máy chủ: {{vm_name}}')
. T::paragraph('Xin chào Quý khách,')
. T::paragraph('Mật khẩu đăng nhập máy chủ ảo <strong>{{vm_name}}</strong> đã được đặt lại thành công. Vui lòng sử dụng mật khẩu mới bên dưới để đăng nhập:')
. T::infoTable([
'Máy chủ' => '{{vm_name}}',
'Mật khẩu mới' => '{{new_password}}',
])
. T::note('Vì lý do bảo mật, vui lòng đổi lại mật khẩu ngay sau khi đăng nhập thành công.')
. T::autoNotice(),
],
];
}
}
@@ -0,0 +1,45 @@
<?php
namespace Database\Seeders\EmailTemplates;
use App\Support\EmailTemplateShell as T;
final class MarketingTemplates
{
/**
* @return array<int, array{name: string, subject: string, body: string, variables: array<int, array{key: string, label: string, example: string}>}>
*/
public static function all(): array
{
return [
[
'name' => 'Khuyến mãi tháng 10',
'subject' => 'Ưu đãi 30% Cloud VPS dành riêng cho {{client_name}}',
'body' => T::title('ƯU ĐÃI THÁNG 10')
. T::greeting('{{client_name}}')
. T::paragraph('Thế Giới Số gửi tặng Quý khách mã giảm <strong>30%</strong> cho mọi gói Cloud VPS đăng ký mới hoặc gia hạn trong tháng 10.')
. T::highlight('{{promo_code}}')
. T::button('{{frontend_url}}', 'Đăng ký ngay'),
'variables' => [['key' => 'promo_code', 'label' => 'Mã khuyến mãi', 'example' => 'TGS30']],
],
[
'name' => 'Thông báo bảo trì hệ thống',
'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}}')
. T::paragraph('Hệ thống sẽ bảo trì định kỳ vào <strong>{{maintenance_date}}</strong>, dự kiến gián đoạn tối đa 30 phút.')
. T::note('Dịch vụ của Quý khách sẽ tự động hoạt động lại sau khi bảo trì.'),
'variables' => [['key' => 'maintenance_date', 'label' => 'Ngày bảo trì', 'example' => '20/10/2026 01:00']],
],
[
'name' => 'Ra mắt Premium Hosting',
'subject' => 'Thế Giới Số ra mắt Premium Hosting thế hệ mới',
'body' => T::title('PREMIUM HOSTING')
. T::greeting('{{client_name}}')
. T::paragraph('Hạ tầng NVMe, LiteSpeed và sao lưu hằng ngày. Chuyển đổi miễn phí từ nhà cung cấp cũ.')
. T::button('https://tgs.com.vn/premium-hosting/', 'Xem bảng giá'),
'variables' => [],
],
];
}
}
@@ -40,6 +40,7 @@ final class TemplateVariables
'ftp_account' => ['Tài khoản FTP', 'string', 'ftp_example'],
'ftp_password' => ['Mật khẩu FTP', 'string', 'Ftp#2026'],
'vm_name' => ['Tên VPS', 'string', 'VPS-Pro-01'],
'new_password' => ['Mật khẩu mới', 'string', 'Xk9#pL2m'],
'vmware_id' => ['VM ID', 'string', 'vm-1024'],
'suspended_at' => ['Thời gian tạm ngưng', 'string', '08/10/2026 09:00'],
'server_name_suffix' => ['[Tự động] Tên máy chủ kèm theo', 'html', ''],
@@ -57,6 +58,10 @@ final class TemplateVariables
'action_status' => ['Trạng thái thao tác', 'string', 'Thành công'],
'domain_name' => ['Tên miền', 'string', 'example.vn'],
'registered_at' => ['Ngày đăng ký', 'string', '08/10/2026'],
'name_server_1' => ['Name server 1', 'string', 'ns9.tgs.com.vn'],
'name_server_2' => ['Name server 2', 'string', 'ns10.tgs.com.vn'],
'kyc_block' => ['[Tự động] Khối xác thực eKYC', 'html', ''],
'hosting_plan' => ['Gói hosting', 'string', 'Premium Hosting 1'],
'server_host_row' => ['[Tự động] Dòng máy chủ / FTP host', 'html', ''],
'ftp_username_row' => ['[Tự động] Dòng tài khoản FTP', 'html', ''],
@@ -15,6 +15,10 @@ class ProductTypeFieldSeeder extends Seeder
['product_type' => 'vps', 'key' => 'ram', 'label' => 'RAM (GB)', 'field_type' => 'number', 'sort_order' => 2],
['product_type' => 'vps', 'key' => 'disk', 'label' => 'Disk (GB)', 'field_type' => 'number', 'sort_order' => 3],
['product_type' => 'vps', 'key' => 'bandwidth', 'label' => 'Băng thông (GB)', 'field_type' => 'number', 'sort_order' => 4],
['product_type' => 'cloud', 'key' => 'cpu', 'label' => 'CPU (vCPU)', 'field_type' => 'number', 'sort_order' => 1],
['product_type' => 'cloud', 'key' => 'ram', 'label' => 'RAM (GB)', 'field_type' => 'number', 'sort_order' => 2],
['product_type' => 'cloud', 'key' => 'disk', 'label' => 'Disk (GB)', 'field_type' => 'number', 'sort_order' => 3],
['product_type' => 'cloud', 'key' => 'bandwidth', 'label' => 'Băng thông (GB)', 'field_type' => 'number', 'sort_order' => 4],
// Hosting
['product_type' => 'hosting', 'key' => 'cpu', 'label' => 'CPU (vCPU)', 'field_type' => 'number', 'sort_order' => 1],
+36 -1
View File
@@ -8,6 +8,8 @@ use App\Support\MailPlaceholders;
use Database\Seeders\EmailTemplates\AccountTemplates;
use Database\Seeders\EmailTemplates\AdminTemplates;
use Database\Seeders\EmailTemplates\BillingTemplates;
use Database\Seeders\EmailTemplates\LegacyTemplates;
use Database\Seeders\EmailTemplates\MarketingTemplates;
use Database\Seeders\EmailTemplates\ServiceTemplates;
use Database\Seeders\EmailTemplates\TemplateVariables;
use Illuminate\Database\Seeder;
@@ -16,9 +18,31 @@ class TemplateSeeder extends Seeder
{
public function run(): void
{
foreach (self::templates() as $item) {
foreach ([...self::templates(), ...self::legacyTemplates()] as $item) {
$this->upsert($item);
}
foreach (MarketingTemplates::all() as $item) {
$this->createMarketingIfMissing($item);
}
}
private function createMarketingIfMissing(array $item): void
{
$template = Template::firstOrNew(['name' => $item['name']]);
if ($template->exists) {
return;
}
$template->fill([
'type_id' => 0,
'format' => 'html',
'subject' => $item['subject'],
'message' => EmailTemplateShell::page($item['body']),
'status' => 1,
]);
$template->syncVariables($item['variables']);
$template->save();
}
/**
@@ -46,6 +70,17 @@ class TemplateSeeder extends Seeder
return [$layout, ...$scenarios];
}
/**
* @return array<int, array{scenario: string, name: string, subject: string, message: string}>
*/
public static function legacyTemplates(): array
{
return array_map(
fn (array $item) => [...$item, 'message' => EmailTemplateShell::page($item['body'])],
LegacyTemplates::all(),
);
}
private function upsert(array $item): void
{
$scenario = $item['scenario'];
+1
View File
@@ -13,6 +13,7 @@ Route::prefix('orders')
->controller(OrderController::class)
->group(function () {
Route::get('/', 'index')->middleware('perm:order.view');
Route::get('/domain-pricing', 'domainPricing')->middleware('perm:order.create');
Route::post('/upload-proof', 'uploadProof')->middleware('perm:order.create');
Route::post('/', 'store')->middleware('perm:order.create');
Route::get('{id}', 'show')->middleware('perm:order.view');
+4
View File
@@ -12,6 +12,7 @@ Route::prefix('products')
->controller(ProductController::class)
->group(function () {
Route::get('/', 'index')->middleware('perm:product-service.view');
Route::get('export', 'export')->middleware('perm:product-service.view');
Route::post('/', 'store')->middleware('perm:product-service.create');
Route::post('bulk-update-status', 'bulkUpdateStatus')->middleware('perm:product-service.edit');
Route::post('bulk-destroy', 'bulkDestroy')->middleware('perm:product-service.destroy');
@@ -36,6 +37,9 @@ Route::prefix('product-categories')
->group(function () {
Route::get('/', 'index')->middleware('perm:product-service.view')->name('index');
Route::post('/', 'store')->middleware('perm:product-service.create')->name('store');
Route::post('seed-sample', 'seedSample')
->middleware('perm:product-service.create')
->name('seed-sample');
Route::get('trashed', 'trashed')->middleware('perm:product-service.view')->name('trashed');
Route::post('bulk-update-status', 'bulkUpdateStatus')
->middleware('perm:product-service.edit')
+10
View File
@@ -2,6 +2,7 @@
use App\Http\Controllers\Api\Admin\Customer\CustomerController;
use App\Http\Controllers\Api\Admin\Service\ServiceController;
use App\Http\Controllers\Api\Admin\Service\ServiceRenewalReminderController;
use Illuminate\Support\Facades\Route;
// Customers (module: customer)
@@ -18,7 +19,11 @@ Route::prefix('customers')
Route::delete('{uuid}/destroy', 'destroy')->middleware('perm:customer.destroy');
Route::post('{uuid}/reset-password', 'resetPassword')->middleware('perm:customer.edit');
Route::post('{uuid}/send-password', 'sendPasswordEmail')->middleware('perm:customer.edit');
Route::post('{uuid}/email-layout', 'emailLayout')->middleware('perm:customer.edit');
Route::post('{uuid}/email-preview', 'emailPreview')->middleware('perm:customer.edit');
Route::post('{uuid}/send-email', 'sendEmail')->middleware('perm:customer.edit');
Route::get('{uuid}/emails', 'emails')->middleware('perm:customer.view');
Route::get('{uuid}/emails/{id}', 'emailDetail')->whereNumber('id')->middleware('perm:customer.view');
Route::post('{uuid}/login-as', 'loginAs')->middleware('perm:customer.edit');
Route::post('{uuid}/add-credit', 'addCredit')->middleware('perm:customer.edit');
Route::post('{uuid}/add-total', 'addTotal')->middleware('perm:customer.edit');
@@ -64,6 +69,11 @@ Route::prefix('customers')
});
// Services (module: customer)
Route::prefix('services/renewal-reminders')->controller(ServiceRenewalReminderController::class)->group(function () {
Route::get('/', 'index')->middleware('perm:customer.view');
Route::post('/', 'send')->middleware('perm:customer.edit');
});
Route::prefix('services')->controller(ServiceController::class)->group(function () {
Route::get('/', 'index')->middleware('perm:customer.view');
Route::post('bulk-action', 'bulkAction')->middleware('perm:customer.edit');
+1
View File
@@ -25,6 +25,7 @@ Route::prefix('service-plans')
->group(function () {
Route::get('/', 'index')->middleware('perm:hosting.view')->name('index');
Route::post('/', 'store')->middleware('perm:hosting.create')->name('store');
Route::get('plesk-plans/{serverId}', 'pleskPlans')->middleware('perm:hosting.view')->whereNumber('serverId')->name('plesk-plans');
Route::get('{id}', 'show')->middleware('perm:hosting.view')->name('show');
Route::put('{id}', 'update')->middleware('perm:hosting.edit')->name('update');
Route::delete('{id}', 'destroy')->middleware('perm:hosting.destroy')->name('destroy');
+1
View File
@@ -137,6 +137,7 @@ Route::prefix('mail-campaigns')
->controller(MailCampaignController::class)
->group(function () {
Route::get('/', 'index')->middleware('perm:mail-campaign.view');
Route::get('variable-suggestions', 'variableSuggestions')->middleware('perm:mail-campaign.create');
Route::post('preview', 'preview')->middleware('perm:mail-campaign.create');
Route::get('{uuid}/preview', 'previewCampaign')->middleware('perm:mail-campaign.view');
Route::get('{uuid}/logs/{id}', 'showLog')->whereNumber('id')->middleware('perm:mail-campaign.view');
@@ -0,0 +1,161 @@
<?php
declare(strict_types=1);
namespace Tests\Feature\Api\Admin\Customer;
use App\Mail\Customer\ManualCustomerEmail;
use App\Mail\GenericTemplateMail;
use App\Models\Customer;
use App\Models\Template;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Mail;
use Spatie\Permission\Models\Role;
use Tests\TestCase;
class SendTemplateEmailTest extends TestCase
{
use RefreshDatabase;
private User $admin;
private Customer $customer;
private Template $template;
protected function setUp(): void
{
parent::setUp();
Role::firstOrCreate(['name' => 'Full Administrator', 'guard_name' => 'admin']);
$this->admin = User::factory()->create();
$this->admin->assignRole('Full Administrator');
$this->customer = Customer::factory()->create(['fullname' => 'Nguyễn Văn A']);
$this->template = Template::create([
'type_id' => 0,
'format' => 'html',
'name' => 'Khuyến mãi',
'subject' => 'Chào {{client_name}}',
'message' => '<p>Xin chào {{$client_name}}, mã {{promo_code}}</p>',
'metadata' => [
'variables' => [
['key' => 'client_name', 'label' => 'Tên', 'type' => 'string', 'example' => 'Quý khách'],
['key' => 'promo_code', 'label' => 'Mã', 'type' => 'string', 'example' => 'TGS10'],
],
'mail' => ['cc' => ['sale@example.com']],
],
'status' => 1,
]);
}
public function test_preview_renders_template_for_customer(): void
{
$this->actingAs($this->admin, 'admin')
->postJson("/api/v1/administrator/customers/{$this->customer->uuid}/email-preview", [
'template_id' => $this->template->id,
])
->assertOk()
->assertJsonPath('data.subject', 'Chào Nguyễn Văn A')
->assertJsonPath('data.html', '<p>Xin chào Nguyễn Văn A, mã TGS10</p>')
->assertJsonPath('data.cc', ['sale@example.com']);
}
public function test_send_uses_edited_html_without_template_cc(): void
{
Mail::fake();
$this->actingAs($this->admin, 'admin')
->postJson("/api/v1/administrator/customers/{$this->customer->uuid}/send-email", [
'template_id' => $this->template->id,
'subject' => 'Tiêu đề đã sửa',
'message' => '<p>Nội dung đã sửa</p>',
'to' => $this->customer->email,
])
->assertOk();
Mail::assertSent(GenericTemplateMail::class, fn (GenericTemplateMail $mail) => $mail->hasTo($this->customer->email)
&& $mail->renderedSubject === 'Tiêu đề đã sửa'
&& $mail->renderedHtml === '<p>Nội dung đã sửa</p>'
&& !array_key_exists('cc', $mail->mailConfig));
$this->assertDatabaseHas('mail', ['customer_id' => $this->customer->id, 'message' => '<p>Nội dung đã sửa</p>']);
}
public function test_email_layout_wraps_content_with_header_and_footer(): void
{
$html = $this->actingAs($this->admin, 'admin')
->postJson("/api/v1/administrator/customers/{$this->customer->uuid}/email-layout", [
'message' => '<p>Xin chào {{client_name}}</p>',
])
->assertOk()
->json('data.html');
$this->assertStringContainsString('<p>Xin chào Nguyễn Văn A</p>', $html);
$this->assertStringContainsString('class="Header"', $html);
$this->assertStringContainsString('class="Footer"', $html);
}
public function test_manual_email_with_inserted_layout_is_not_wrapped_again(): void
{
Mail::fake();
$this->actingAs($this->admin, 'admin')
->postJson("/api/v1/administrator/customers/{$this->customer->uuid}/send-email", [
'subject' => 'Thông báo',
'message' => '<table class="Header"></table><p>Nội dung</p>',
'to' => $this->customer->email,
'with_layout' => false,
])
->assertOk();
Mail::assertQueued(ManualCustomerEmail::class, function (ManualCustomerEmail $mail) {
$html = $mail->render();
return substr_count($html, 'class="Header"') === 1 && str_contains($html, '<p>Nội dung</p>');
});
}
public function test_sent_emails_are_listed_with_sender_and_bcc(): void
{
Mail::fake();
$base = "/api/v1/administrator/customers/{$this->customer->uuid}";
$this->actingAs($this->admin, 'admin')->postJson("{$base}/send-email", [
'subject' => 'Thông tin VPS',
'message' => '<p>Thông tin</p>',
'to' => $this->customer->email,
'bcc' => 'audit@example.com',
])->assertOk();
$item = $this->getJson("{$base}/emails")
->assertOk()
->assertJsonPath('data.total', 1)
->assertJsonPath('data.items.0.bcc', 'audit@example.com')
->assertJsonPath('data.items.0.to', $this->customer->email)
->assertJsonPath('data.items.0.sent_by', $this->admin->name ?: $this->admin->username)
->assertJsonMissingPath('data.items.0.html')
->json('data.items.0');
$this->getJson("{$base}/emails/{$item['id']}")
->assertOk()
->assertJsonPath('data.html', '<p>Thông tin</p>');
$other = Customer::factory()->create();
$this->getJson("/api/v1/administrator/customers/{$other->uuid}/emails/{$item['id']}")->assertNotFound();
}
public function test_send_reports_error_when_mail_is_disabled(): void
{
\App\Models\Setting::updateOrCreate(['key' => 'email_settings'], ['value' => json_encode(['mail_enabled' => false])]);
$this->actingAs($this->admin, 'admin')
->postJson("/api/v1/administrator/customers/{$this->customer->uuid}/send-email", [
'subject' => 'Thông báo',
'message' => '<p>Nội dung</p>',
])
->assertStatus(422);
$this->assertDatabaseMissing('mail', ['customer_id' => $this->customer->id]);
}
}
@@ -0,0 +1,40 @@
<?php
declare(strict_types=1);
namespace Tests\Feature\Api\Admin\Customer;
use App\Models\Customer;
use App\Models\OrderItem;
use App\Models\Service;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Schema;
use Spatie\Permission\Models\Role;
use Tests\TestCase;
class UpdateServiceBillingCycleTest extends TestCase
{
use RefreshDatabase;
public function test_updates_order_item_billing_cycle_and_months(): void
{
Role::firstOrCreate(['name' => 'Full Administrator', 'guard_name' => 'admin']);
$admin = User::factory()->create();
$admin->assignRole('Full Administrator');
Schema::disableForeignKeyConstraints();
$customer = Customer::factory()->create();
$item = OrderItem::create(['order_id' => 1, 'product_id' => 1, 'billing_cycle' => 'monthly', 'months' => 1, 'quantity' => 1, 'price' => 100000]);
$service = Service::create(['customer_id' => $customer->id, 'order_item_id' => $item->id, 'status' => 'active']);
$url = "/api/v1/administrator/customers/{$customer->uuid}/services/{$service->id}";
$this->actingAs($admin, 'admin')->putJson($url, ['billing_cycle' => 'annually'])->assertOk();
$item->refresh();
$this->assertSame('annually', $item->billing_cycle);
$this->assertSame(12, (int) $item->months);
$this->actingAs($admin, 'admin')->putJson($url, ['billing_cycle' => 'onetime'])->assertStatus(422);
}
}
@@ -0,0 +1,123 @@
<?php
declare(strict_types=1);
namespace Tests\Feature\Api\Admin\Hosting;
use App\Models\DnsServer;
use App\Models\ServicePlan;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Crypt;
use Illuminate\Support\Facades\Http;
use Spatie\Permission\Models\Role;
use Tests\TestCase;
class ServicePlanTest extends TestCase
{
use RefreshDatabase;
private User $admin;
protected function setUp(): void
{
parent::setUp();
Role::firstOrCreate(['name' => 'Full Administrator', 'guard_name' => 'admin']);
$this->admin = User::factory()->create();
$this->admin->assignRole('Full Administrator');
}
private function server(string $type = 'hosting'): DnsServer
{
return DnsServer::create([
'name' => 'plesk-01',
'ip_address' => '10.0.0.5',
'username' => 'admin',
'password' => Crypt::encryptString('secret'),
'status' => 'activated',
'type' => $type,
]);
}
public function test_store_and_update_persist_limits_features_and_plesk_mapping(): void
{
$payload = [
'title' => 'Hosting Basic',
'description' => 'Cho website nhỏ',
'vcpu' => 1,
'ram' => 1,
'disk' => 10,
'domain' => 1,
'bandwidth' => null,
'databases' => 2,
'mailboxes' => 5,
'mailbox_quota' => 1024,
'features' => ['SSL miễn phí', 'Backup hàng ngày'],
'is_active' => false,
'sort_order' => 3,
'plesk_plan_name' => 'basic',
];
$id = $this->actingAs($this->admin, 'admin')
->postJson('/api/v1/administrator/service-plans', $payload)
->assertCreated()
->assertJsonPath('data.features', ['SSL miễn phí', 'Backup hàng ngày'])
->assertJsonPath('data.is_active', false)
->assertJsonPath('data.plesk.plesk_plan_name', 'basic')
->json('data.id');
$plan = ServicePlan::findOrFail($id);
$this->assertNull($plan->bandwidth);
$this->assertSame(5, $plan->mailboxes);
$this->actingAs($this->admin, 'admin')
->putJson("/api/v1/administrator/service-plans/{$id}", [...$payload, 'is_active' => true, 'plesk_plan_name' => null])
->assertOk()
->assertJsonPath('data.is_active', true)
->assertJsonPath('data.plesk', null);
}
public function test_rejects_negative_limits_and_duplicate_features(): void
{
$this->actingAs($this->admin, 'admin')
->postJson('/api/v1/administrator/service-plans', [
'title' => 'X', 'vcpu' => 1, 'ram' => 1, 'disk' => 1, 'domain' => 1,
'databases' => -1,
'features' => ['SSH', 'SSH'],
])
->assertStatus(422)
->assertJsonValidationErrors(['databases', 'features.0']);
}
public function test_lists_plesk_plan_names_from_cli_gateway(): void
{
$server = $this->server();
Http::fake([
'https://10.0.0.5:8443/api/v2/cli/service_plan/call' => Http::response([
'code' => 0, 'stdout' => "Default Domain\r\nbasic\n\nbasic\n", 'stderr' => '',
]),
]);
$this->actingAs($this->admin, 'admin')
->getJson("/api/v1/administrator/service-plans/plesk-plans/{$server->id}")
->assertOk()
->assertExactJson(['error' => false, 'data' => ['Default Domain', 'basic']]);
Http::assertSent(fn ($request) => $request['params'] === ['--list']);
}
public function test_plesk_plans_fails_for_cli_error_and_non_hosting_server(): void
{
$server = $this->server();
Http::fake(['*' => Http::response(['code' => 1, 'stdout' => '', 'stderr' => 'denied'])]);
$this->actingAs($this->admin, 'admin')
->getJson("/api/v1/administrator/service-plans/plesk-plans/{$server->id}")
->assertStatus(502);
$this->actingAs($this->admin, 'admin')
->getJson('/api/v1/administrator/service-plans/plesk-plans/' . $this->server('domain')->id)
->assertNotFound();
}
}
@@ -0,0 +1,78 @@
<?php
declare(strict_types=1);
namespace Tests\Feature\Api\Admin\Service;
use App\Mail\ServiceRenewalReminderMail;
use App\Models\Customer;
use App\Models\Service;
use App\Models\Setting;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Mail;
use Spatie\Permission\Models\Role;
use Tests\TestCase;
class RenewalReminderTest extends TestCase
{
use RefreshDatabase;
private const BASE = '/api/v1/administrator/services/renewal-reminders';
private User $admin;
private Service $soon;
private Service $later;
protected function setUp(): void
{
parent::setUp();
$this->seed(\Database\Seeders\TemplateSeeder::class);
Role::firstOrCreate(['name' => 'Full Administrator', 'guard_name' => 'admin']);
$this->admin = User::factory()->create();
$this->admin->assignRole('Full Administrator');
$customer = Customer::factory()->create();
$this->soon = Service::create(['customer_id' => $customer->id, 'status' => 'active', 'expires_at' => now()->addDays(5)]);
$this->later = Service::create(['customer_id' => $customer->id, 'status' => 'active', 'expires_at' => now()->addDays(40)]);
Service::create(['customer_id' => $customer->id, 'status' => 'suspended', 'expires_at' => now()->addDays(3)]);
}
public function test_lists_active_services_expiring_within_days(): void
{
$this->actingAs($this->admin, 'admin')
->getJson(self::BASE . '?days=30')
->assertOk()
->assertJsonCount(1, 'data')
->assertJsonPath('data.0.id', $this->soon->id)
->assertJsonPath('data.0.days_left', 5);
}
public function test_send_queues_reminders_bypassing_auto_disabled_scenario(): void
{
Mail::fake();
Setting::updateOrCreate(['key' => 'email_settings'], ['value' => json_encode(['auto_disabled' => ['service_renewal_reminder']])]);
$this->actingAs($this->admin, 'admin')
->postJson(self::BASE, ['service_ids' => [$this->soon->id, $this->later->id]])
->assertOk();
Mail::assertQueued(ServiceRenewalReminderMail::class, 2);
$this->assertDatabaseCount('mail', 2);
$this->assertDatabaseHas('mail', ['customer_id' => $this->soon->customer_id, 'sent_by' => $this->admin->id]);
}
public function test_send_is_rejected_when_mail_is_disabled(): void
{
Mail::fake();
Setting::updateOrCreate(['key' => 'email_settings'], ['value' => json_encode(['mail_enabled' => false])]);
$this->actingAs($this->admin, 'admin')
->postJson(self::BASE, ['service_ids' => [$this->soon->id]])
->assertStatus(422);
Mail::assertNothingQueued();
}
}
@@ -145,6 +145,35 @@ class InvoicePaymentServiceTest extends TestCase
$this->assertSame(400_000, (int) $customer->fresh()->total);
}
public function test_admin_payment_records_source_and_note(): void
{
$customer = Customer::factory()->create(['total' => 0]);
$invoice = $this->makeInvoice($customer, 100_000);
$invoice = app(InvoicePaymentService::class)->markPaidByAdmin(
$invoice,
null,
method: 'manual',
receivedAmount: 40_000,
note: 'Khách chuyển khoản đợt 1',
);
$this->assertSame(40_000, (int) $invoice->amount_paid);
$this->assertSame('Khách chuyển khoản đợt 1', $invoice->note);
$this->assertDatabaseHas('transactions', [
'customer_id' => $customer->id,
'received_amount' => 40_000,
]);
$this->assertStringContainsString(
'Khách chuyển khoản đợt 1',
(string) Transaction::where('customer_id', $customer->id)->value('note'),
);
$this->assertStringContainsString(
'(manual)',
(string) Transaction::where('customer_id', $customer->id)->value('note'),
);
}
/** Hoàn tiền: cộng lại số dư, ghi Transaction âm, hoá đơn sang REFUNDED. */
public function test_refund_returns_balance_and_records_a_negative_transaction(): void
{
+10 -1
View File
@@ -30,9 +30,18 @@ class TemplateSeederTest extends TestCase
}
}
public function test_legacy_templates_use_the_shared_shell(): void
{
foreach (TemplateSeeder::legacyTemplates() as $item) {
$this->assertNull(EmailScenarioRegistry::find($item['scenario']), $item['scenario']);
$this->assertStringContainsString('class="Header"', $item['message'], $item['scenario']);
$this->assertStringContainsString('class="Footer"', $item['message'], $item['scenario']);
}
}
public function test_every_placeholder_has_a_variable_definition(): void
{
foreach (TemplateSeeder::templates() as $item) {
foreach ([...TemplateSeeder::templates(), ...TemplateSeeder::legacyTemplates()] as $item) {
$unknown = array_diff(MailPlaceholders::extract($item['subject'], $item['message']), array_keys(TemplateVariables::DEFINITIONS));
$this->assertSame([], array_values($unknown), $item['scenario']);
}