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
+42 -3
View File
@@ -2,7 +2,9 @@
namespace App\Support\Billing;
use App\Models\ActivityLog;
use App\Models\Invoice;
use App\Models\Service;
use App\Models\Transaction;
use Illuminate\Support\Collection;
use Illuminate\Support\Str;
@@ -43,11 +45,12 @@ class InvoicePaymentHistory
->get();
$invoiceByNumber = $invoices->keyBy('invoice_number');
$renewalCausers = self::renewalCausers($invoices);
foreach ($transactions as $transaction) {
$invoice = self::matchInvoice($transaction, $invoiceByNumber, $invoiceByOrderNum);
if ($invoice) {
$history[$invoice->id][] = self::present($transaction);
$history[$invoice->id][] = self::present($transaction, $renewalCausers[$invoice->invoice_number] ?? null);
}
}
@@ -65,7 +68,42 @@ class InvoicePaymentHistory
return $invoiceByNumber->get($number);
}
private static function present(Transaction $transaction): array
private static function renewalCausers(Collection $invoices): array
{
$numbers = $invoices->where('invoice_type', 2)->pluck('invoice_number')->all();
if ($numbers === []) {
return [];
}
return ActivityLog::where('subject_type', Service::class)
->where('event', 'renewed')
->whereIn('new_values->invoice_number', $numbers)
->get(['causer_type', 'new_values'])
->mapWithKeys(fn (ActivityLog $log) => [$log->new_values['invoice_number'] => $log->causer_type])
->all();
}
private static function method(Transaction $transaction, bool $isRefund, ?string $renewalCauser): string
{
if ($isRefund) {
return 'refund';
}
if (in_array($transaction->type, ['transfer', 'balance'], true)) {
return $transaction->type;
}
if (preg_match('/\(([a-z_]+)\)$/', (string) $transaction->note, $match) !== 1) {
return 'other';
}
return match (true) {
$match[1] === 'balance' && $renewalCauser === 'system' => 'auto_renew',
default => $match[1],
};
}
private static function present(Transaction $transaction, ?string $renewalCauser): array
{
$isRefund = Str::endsWith((string) $transaction->trans, '-REFUND');
@@ -75,7 +113,8 @@ class InvoicePaymentHistory
'kind' => $isRefund ? 'refund' : 'payment',
'amount' => abs((int) $transaction->amount),
'type' => $transaction->type,
'note' => $transaction->note,
'method' => self::method($transaction, $isRefund, $renewalCauser),
'note' => preg_replace('/ \([a-z_]+\)$/', '', (string) $transaction->note) ?: null,
'recorded_by' => $transaction->user?->fullname ?: $transaction->user?->username,
'created_at' => $transaction->created_at,
];
+8
View File
@@ -189,6 +189,14 @@ final class EmailScenarioRegistry
'label' => '[Admin] Ticket hỗ trợ mới', 'category' => self::INTERNAL, 'group' => 'SUPPORT',
'required_variables' => ['ticket_num'],
],
'admin_customer_service_renewed' => [
'label' => '[Admin] Khách hàng gia hạn dịch vụ', 'category' => self::INTERNAL, 'group' => 'SERVICE',
'required_variables' => ['product_name', 'expires_at'],
],
'admin_service_cancellation_request' => [
'label' => '[Admin] Khách yêu cầu hủy dịch vụ', 'category' => self::INTERNAL, 'group' => 'SERVICE',
'required_variables' => ['customer_name', 'cancel_reason'],
],
'admin_login_alert' => [
'label' => '[Admin] Có người đăng nhập trang quản trị', 'category' => self::INTERNAL, 'group' => 'SECURITY',
'required_variables' => ['admin_username', 'client_ip', 'login_time'],
+35
View File
@@ -0,0 +1,35 @@
<?php
namespace App\Support;
use App\Models\ModuleHostingPleskClient;
use App\Models\Service;
use Illuminate\Support\Facades\Crypt;
class HostingCredentials
{
public static function attach(iterable $services): void
{
foreach ($services as $service) {
$service->setAttribute('hosting_credentials', self::for($service));
}
}
public static function for(Service $service): ?array
{
$hosting = $service->hostingAccount;
$client = $hosting ? ModuleHostingPleskClient::forHosting($hosting) : null;
if (!$client) {
return null;
}
return [
'host' => $hosting->server?->publicHost(),
'ip' => $hosting->server?->ip_address,
'panel_url' => $hosting->server?->panelUrl(),
'username' => $client->login,
'password' => Crypt::decryptString($client->password),
];
}
}
+13 -32
View File
@@ -1,44 +1,25 @@
<?php
declare(strict_types=1);
namespace App\Support;
use RuntimeException;
class ResponseCipher
{
private const CIPHER = 'aes-256-gcm';
public static function keyFrom(?string $encoded): ?string
public static function encrypt(?string $value): ?string
{
$key = $encoded ? base64_decode($encoded, true) : false;
return $key !== false && strlen($key) === 32 ? $key : null;
}
public static function seal(string $plain, string $key): string
{
$iv = random_bytes(12);
$cipherText = openssl_encrypt($plain, self::CIPHER, $key, OPENSSL_RAW_DATA, $iv, $tag);
return base64_encode($iv . $cipherText . $tag);
}
public static function open(string $sealed, string $key): ?string
{
$raw = base64_decode($sealed, true);
if ($raw === false || strlen($raw) < 29) {
return null;
if ($value === null || $value === '') {
return $value;
}
$plain = openssl_decrypt(
substr($raw, 12, -16),
self::CIPHER,
$key,
OPENSSL_RAW_DATA,
substr($raw, 0, 12),
substr($raw, -16),
);
$key = (string) config('services.client_api.response_key');
if (!preg_match('/^[a-fA-F0-9]{64}$/', $key)) {
throw new RuntimeException('CLIENT_RESPONSE_KEY must be a 64-character hex string');
}
return $plain === false ? null : $plain;
$iv = random_bytes(12);
$ciphertext = openssl_encrypt($value, 'aes-256-gcm', hex2bin($key), OPENSSL_RAW_DATA, $iv, $tag);
return base64_encode($iv . $ciphertext . $tag);
}
}
+54
View File
@@ -0,0 +1,54 @@
<?php
namespace App\Support;
use App\Enums\Invoice\InvoiceStatus;
use App\Jobs\VirtualMachine\Action\VMJobs;
use App\Models\Invoice;
use App\Models\Service;
class ServiceCancellation
{
public const SCHEDULED = 'scheduled';
public const IMMEDIATE = 'immediate';
public static function approve(Service $service): string
{
self::voidOpenRenewalInvoices($service);
if (! $service->expires_at || $service->expires_at->isPast()) {
$service->update(['status' => 'cancelled', 'cancelled_at' => now(), 'auto_renew' => false]);
return self::IMMEDIATE;
}
$service->update(['cancel_at_expiry' => true, 'auto_renew' => false]);
return self::SCHEDULED;
}
public static function finalize(Service $service): void
{
$service->update(['status' => 'cancelled', 'cancelled_at' => now()]);
if ($service->vm_id) {
dispatch(new VMJobs((int) $service->vm_id, 'PowerOff', false));
}
}
private static function voidOpenRenewalInvoices(Service $service): void
{
$orderId = $service->orderItem?->order_id;
if (! $orderId) {
return;
}
Invoice::where('order_id', $orderId)
->where('invoice_type', 2)
->whereIn('status', [InvoiceStatus::UNPAID->value, InvoiceStatus::OVERDUE->value])
->where(fn ($query) => $query->whereNull('amount_paid')->orWhere('amount_paid', 0))
->get()
->each(fn (Invoice $invoice) => $invoice->update(['status' => InvoiceStatus::CANCELLED->value]));
}
}
+58
View File
@@ -0,0 +1,58 @@
<?php
declare(strict_types=1);
namespace App\Support;
use Illuminate\Database\Eloquent\Builder;
class ServiceGroup
{
public const SSL = 'ssl';
public const COLO = 'colo';
public const DEDICATED = 'dedicated';
public const EMAIL = 'email';
public const ALL = [self::SSL, self::COLO, self::DEDICATED, self::EMAIL];
private const UNTYPED_PRODUCT_TYPES = ['other', 'server_rental'];
public static function of(?string $productType, ?string $productName): ?string
{
$name = mb_strtolower(trim((string) $productName));
return match (true) {
$productType === 'email' => self::EMAIL,
$productType === 'ssl' => self::SSL,
! in_array($productType, self::UNTYPED_PRODUCT_TYPES, true) => null,
str_starts_with($name, 'ssl') => self::SSL,
str_contains($name, 'colo') || str_contains($name, 'chỗ đặt') => self::COLO,
$productType === 'server_rental' || str_contains($name, 'dedicated') => self::DEDICATED,
default => null,
};
}
public static function constrain(Builder $products, string $group): Builder
{
$untyped = fn (Builder $query) => $query->whereIn('product_type', self::UNTYPED_PRODUCT_TYPES);
$notSsl = fn (Builder $query) => $query->whereRaw("TRIM(name) NOT LIKE 'ssl%'");
$notColo = fn (Builder $query) => $query->where('name', 'not like', '%colo%')->where('name', 'not like', '%chỗ đặt%');
return match ($group) {
self::EMAIL => $products->where('product_type', 'email'),
self::SSL => $products->where(fn (Builder $query) => $query
->where('product_type', 'ssl')
->orWhere(fn (Builder $other) => $untyped($other)->whereRaw("TRIM(name) LIKE 'ssl%'"))),
self::COLO => $untyped($products)->tap($notSsl)->where(fn (Builder $query) => $query
->where('name', 'like', '%colo%')
->orWhere('name', 'like', '%chỗ đặt%')),
self::DEDICATED => $untyped($products)->tap($notSsl)->tap($notColo)->where(fn (Builder $query) => $query
->where('product_type', 'server_rental')
->orWhere('name', 'like', '%dedicated%')),
default => $products->whereRaw('1 = 0'),
};
}
}
+67
View File
@@ -0,0 +1,67 @@
<?php
namespace App\Support;
use App\Models\ServiceSslCertificate;
use Illuminate\Http\UploadedFile;
use InvalidArgumentException;
use ZipArchive;
class SslBundleExtractor
{
private const MAX_TOTAL_BYTES = 2097152;
public static function extract(UploadedFile $archive): array
{
$zip = new ZipArchive();
if ($zip->open($archive->getRealPath(), ZipArchive::RDONLY) !== true) {
throw new InvalidArgumentException('Không đọc được file zip.');
}
$files = [];
$total = 0;
for ($index = 0; $index < $zip->numFiles; $index++) {
$name = self::cleanName($zip->getNameIndex($index));
if ($name === null) {
continue;
}
$content = $zip->getFromIndex($index, ServiceSslCertificate::MAX_FILE_BYTES + 1);
if ($content === false || strlen($content) > ServiceSslCertificate::MAX_FILE_BYTES || ! self::isText($content)) {
continue;
}
$total += strlen($content);
if ($total > self::MAX_TOTAL_BYTES || count($files) >= ServiceSslCertificate::MAX_ENTRIES) {
throw new InvalidArgumentException('File zip chứa quá nhiều dữ liệu.');
}
$files[$name] = ['name' => $name, 'content' => $content];
}
$zip->close();
if ($files === []) {
throw new InvalidArgumentException('File zip không có tệp văn bản nào (pem, crt, key, txt...).');
}
return array_values($files);
}
private static function cleanName(string $entry): ?string
{
if (str_ends_with($entry, '/') || str_contains($entry, '__MACOSX/')) {
return null;
}
$name = trim(basename($entry));
return $name === '' || str_starts_with($name, '.') ? null : mb_substr($name, 0, 150);
}
private static function isText(string $content): bool
{
return ! str_contains($content, "\0") && mb_check_encoding($content, 'UTF-8');
}
}