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:
co-authored by
Claude Sonnet 5.5
parent
a6b641015e
commit
b44a88bb52
@@ -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,
|
||||
];
|
||||
|
||||
@@ -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'],
|
||||
|
||||
@@ -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),
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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]));
|
||||
}
|
||||
}
|
||||
@@ -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'),
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -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');
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user