feat(service): huỷ dịch vụ, SSL, đổi mật khẩu hosting, lịch sử thanh toán theo hình thức

- Lịch sử thanh toán hoá đơn trả thêm `method` (tự động gia hạn / số dư / admin / chuyển khoản / hoàn tiền), bỏ đuôi `(method)` khỏi ghi chú
- `DnsServer::panelUrl()` = https://<domain>/ dùng cho email cấp hosting và nút Plesk của khách
- Order item trả thêm `uuid`/`type` của dịch vụ để FE dẫn tới trang quản lý
- Yêu cầu huỷ dịch vụ, chứng chỉ SSL, lịch sử gia hạn, đổi mật khẩu hosting cho khách

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
baodev0
2026-10-28 16:40:24 +07:00
co-authored by Claude Sonnet 5.5
parent a6b641015e
commit b44a88bb52
72 changed files with 1701 additions and 231 deletions
@@ -0,0 +1,14 @@
<?php
use Database\Seeders\TemplateSeeder;
use Illuminate\Database\Migrations\Migration;
return new class extends Migration
{
public function up(): void
{
(new TemplateSeeder())->seedServiceLifecycleTemplates();
}
public function down(): void {}
};
@@ -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('service_attachments', function (Blueprint $table) {
$table->string('audience', 20)->default('admin')->after('service_id')->index();
});
}
public function down(): void
{
Schema::table('service_attachments', function (Blueprint $table) {
$table->dropColumn('audience');
});
}
};
@@ -0,0 +1,23 @@
<?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::create('service_ssl_certificates', function (Blueprint $table) {
$table->id();
$table->foreignId('service_id')->unique()->constrained('services')->cascadeOnDelete();
$table->longText('data');
$table->timestamps();
});
}
public function down(): void
{
Schema::dropIfExists('service_ssl_certificates');
}
};
@@ -0,0 +1,31 @@
<?php
use Database\Seeders\TemplateSeeder;
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::create('service_cancellation_requests', function (Blueprint $table) {
$table->id();
$table->foreignId('service_id')->constrained('services')->cascadeOnDelete();
$table->foreignId('customer_id')->constrained('customers')->cascadeOnDelete();
$table->string('reason_code', 40)->nullable();
$table->text('reason');
$table->string('status', 20)->default('pending')->index();
$table->timestamps();
});
(new TemplateSeeder())->seedScenarioIfMissing('admin_service_cancellation_request');
}
public function down(): void
{
Schema::dropIfExists('service_cancellation_requests');
DB::table('template')->where('scenario', 'admin_service_cancellation_request')->delete();
}
};
@@ -0,0 +1,24 @@
<?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_cancellation_requests', function (Blueprint $table) {
$table->text('admin_note')->nullable()->after('reason');
$table->timestamp('resolved_at')->nullable()->after('status');
$table->string('resolved_by', 100)->nullable()->after('resolved_at');
});
}
public function down(): void
{
Schema::table('service_cancellation_requests', function (Blueprint $table) {
$table->dropColumn(['admin_note', 'resolved_at', 'resolved_by']);
});
}
};
@@ -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('services', function (Blueprint $table) {
$table->boolean('cancel_at_expiry')->default(false)->after('auto_renew')->index();
});
}
public function down(): void
{
Schema::table('services', function (Blueprint $table) {
$table->dropColumn('cancel_at_expiry');
});
}
};
@@ -0,0 +1,18 @@
<?php
use Database\Seeders\TemplateSeeder;
use Illuminate\Database\Migrations\Migration;
use Illuminate\Support\Facades\DB;
return new class extends Migration
{
public function up(): void
{
(new TemplateSeeder())->seedScenarioIfMissing('admin_customer_service_renewed');
}
public function down(): void
{
DB::table('template')->where('scenario', 'admin_customer_service_renewed')->delete();
}
};
@@ -166,6 +166,44 @@ final class AdminTemplates
. T::note('{{description}}', 'Nội dung:')
. T::button('{{ticket_url}}', 'XEM TICKET'),
],
[
'scenario' => 'admin_customer_service_renewed',
'name' => '[Admin] Khách hàng gia hạn dịch vụ',
'subject' => '[ADMIN] {{customer_name}} gia hạn dịch vụ {{product_name}}',
'body' => T::title('Khách hàng vừa gia hạn dịch vụ')
. T::paragraph('Một dịch vụ trên hệ thống <strong>{{company_name}}</strong> vừa được gia hạn thành công.')
. T::infoTable([
'Hình thức' => '{{renewal_source_label}}',
'Khách hàng' => '{{customer_name}}',
'Email' => '{{customer_email}}',
'Dịch vụ' => '{{product_name}}{{server_name_suffix}}',
'Thời gian gia hạn' => '{{months_label}}',
'Hạn sử dụng trước' => '{{old_expires_at}}',
'Hạn sử dụng mới' => '{{expires_at}}',
'Hoá đơn' => '{{invoice_number}}',
'Số tiền' => '{{amount_formatted}}',
'Thanh toán' => '{{payment_method_label}}',
])
. T::button('{{admin_url}}', 'XEM DỊCH VỤ'),
],
[
'scenario' => 'admin_service_cancellation_request',
'name' => '[Admin] Khách yêu cầu hủy dịch vụ',
'subject' => '[ADMIN] {{customer_name}} yêu cầu hủy dịch vụ {{product_name}}',
'body' => T::title('Khách hàng yêu cầu hủy dịch vụ')
. T::paragraph('Khách hàng vừa gửi yêu cầu hủy dịch vụ trên hệ thống <strong>{{company_name}}</strong>. Dịch vụ chưa bị hủy, cần nhân viên liên hệ và xử lý.')
. T::infoTable([
'Khách hàng' => '{{customer_name}}',
'Email' => '{{customer_email}}',
'Dịch vụ' => '{{product_name}}',
'Tên dịch vụ' => '{{server_name}}',
'Ngày hết hạn' => '{{expires_at}}',
'Lý do hủy' => '{{cancel_reason}}',
'Thời gian' => '{{created_at}}',
'Địa chỉ IP' => '{{client_ip}}',
])
. T::button('{{admin_url}}', 'XEM DỊCH VỤ'),
],
[
'scenario' => 'admin_login_alert',
'name' => '[Admin] Có người đăng nhập trang quản trị',
@@ -132,6 +132,10 @@ final class ServiceLifecycleTemplates
$items[] = self::genericNewService($hasAccount);
}
foreach ([true, false] as $withPassword) {
$items[] = self::resendAccount($withPassword);
}
return $items;
}
@@ -193,6 +197,27 @@ final class ServiceLifecycleTemplates
);
}
private static function resendAccount(bool $withPassword): array
{
$loginLink = '<a style="color: #c2410c;" href="{{login_url}}" target="_blank" rel="noopener">{{login_url}}</a>';
$rows = ['Trang đăng nhập' => $loginLink, 'Email đăng nhập' => '{{account_email}}'];
$rows['Mật khẩu'] = $withPassword ? '{{account_pass}}' : 'Mật khẩu hiện tại của Quý khách';
return self::template(
'Tài khoản — Gửi lại thông tin tài khoản (' . ($withPassword ? 'kèm mật khẩu' : 'không kèm mật khẩu') . ')',
'V/v: Thế Giới Số gửi lại thông tin tài khoản | {{client_name}}',
T::greeting('{{client_name}}')
. T::paragraph('Theo yêu cầu của Quý khách, <strong>{{app_name}}</strong> xin gửi lại thông tin tài khoản đăng nhập như sau:')
. T::infoTable($rows)
. ($withPassword
? T::note('Quý khách vui lòng đổi mật khẩu ngay sau khi đăng nhập và không chia sẻ thông tin tài khoản cho người khác.', 'Bảo mật:')
: T::note('Nếu không nhớ mật khẩu, Quý khách chọn <strong>"Quên mật khẩu"</strong> tại trang đăng nhập để đặt lại.'))
. T::button('{{login_url}}', 'ĐĂNG NHẬP')
. T::supportLine()
. T::paragraph('Trân trọng,'),
);
}
private static function genericNewService(bool $hasAccount): array
{
$accessRows = '';
@@ -39,6 +39,8 @@ final class SubjectFormat
'admin_trial_registration_request' => ['[ADMIN] yêu cầu dùng thử Cloud VPS', null, 'customer_name'],
'admin_manual_deposit_pending' => ['[ADMIN] yêu cầu nạp tiền chờ duyệt {{transaction_code}}', null, 'customer_name'],
'admin_ticket_notification' => ['[ADMIN] ticket mới #{{ticket_num}}', null, 'customer_name'],
'admin_customer_service_renewed' => ['[ADMIN] {{customer_name}} gia hạn dịch vụ {{product_name}}', null, null],
'admin_service_cancellation_request' => ['[ADMIN] {{customer_name}} yêu cầu hủy dịch vụ {{product_name}}', null, null],
'admin_login_alert' => ['[ADMIN] {{admin_username}} đăng nhập trang quản trị từ IP {{client_ip}}', null, null],
'admin_payment_received' => ['[ADMIN] khách hàng thanh toán {{payment_type}} #{{reference}}', null, 'customer_name'],
'admin_service_renewed' => ['[ADMIN] {{admin_name}} gia hạn dịch vụ', 'product_name', 'customer_name'],
@@ -112,6 +112,8 @@ final class TemplateVariables
'status_line' => ['Tình trạng hạn thanh toán', 'string', 'Hoá đơn sẽ đến hạn sau 3 ngày.'],
'paid_row' => ['[Tự động] Dòng đã thanh toán', 'html', ''],
'transaction_code' => ['Mã giao dịch', 'string', 'NAP20261008001'],
'renewal_source_label' => ['Hình thức gia hạn', 'string', 'Khách hàng tự gia hạn'],
'cancel_reason' => ['Lý do hủy dịch vụ', 'string', 'Không còn nhu cầu sử dụng'],
'admin_username' => ['Tài khoản quản trị', 'string', 'admin'],
'login_time' => ['Thời gian đăng nhập', 'string', '23/10/2026 08:30:00'],
'client_ip' => ['Địa chỉ IP', 'string', '203.113.10.20'],