Files
portal-api/app/Services/Concerns/ActivatesPaidInvoices.php
baodev0andClaude Sonnet 5.5 55a834a2f8 feat: nhắc gia hạn QR theo dịch vụ, failover server hosting, CCCD admin, log cấp phát dịch vụ
- Mail campaign: mẫu nhắc gia hạn thanh toán QR, chọn dịch vụ + chu kỳ từng dòng (renewal_services)
- Hosting: nhiều server Plesk cho một gói (service_plan_servers), chuyển server khi server chết
- Admin xem/sửa giấy tờ định danh khách, tuỳ chọn gia hạn cho khách, nhật ký cấp phát dịch vụ
- Đánh dấu mẫu email dịch vụ là non-marketing

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-30 18:36:41 +07:00

305 lines
12 KiB
PHP

<?php
namespace App\Services\Concerns;
use App\Enums\Order\OrderStatus;
use App\Helpers\OrderHelper;
use App\Jobs\Hosting\ProvisionHostingAccountJob;
use App\Jobs\VirtualMachine\Action\VMJobs;
use App\Jobs\VirtualMachine\Create\CreateVirtualMachineJob;
use App\Jobs\VirtualMachine\Provision\UpdateVmConfigJob;
use App\Http\Services\vCenter as ServiceVCenter;
use App\Models\Addon;
use App\Models\Invoice;
use App\Models\OrderItem;
use App\Models\Service;
use App\Models\ServiceAddon;
use App\Services\ActivityLogger;
use App\Services\CustomerRenewalNotifier;
use Carbon\Carbon;
use Carbon\CarbonInterface;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Log;
use App\Support\ServiceProvisionLog;
trait ActivatesPaidInvoices
{
private function activateOrder(Invoice $invoice): void
{
$order = $invoice->order?->loadMissing(['orderItems.product', 'customer', 'invoice']);
if (! $order) {
return;
}
// payment_method/paid_at always mirror the invoice that was just paid, even if the
// order's status doesn't change (e.g. it's already ACTIVE from an earlier payment).
$order->update([
'status' => OrderStatus::ACTIVE->value,
'payment_method' => $invoice->payment_method,
'paid_at' => $invoice->paid_at,
]);
foreach ($order->orderItems as $item) {
$isHostingProvision = ! $item->os_id
&& $item->server_name
&& $item->product?->product_type === 'hosting';
$service = Service::firstOrCreate(
['order_item_id' => $item->id],
[
'customer_id' => $order->customer_id,
'product_id' => $item->product_id,
'invoice_id' => $invoice->id,
'server_name' => $item->server_name,
'status' => ($item->os_id || $isHostingProvision) ? 'pending' : 'active',
'auto_renew' => false,
'started_at' => now(),
'expires_at' => now()->addMonths($item->months ?: OrderHelper::cycleToMonths($item->billing_cycle)),
]
);
$this->attachServiceAddons($service, $item);
if ($service->wasRecentlyCreated && $service->status === 'pending') {
ServiceProvisionLog::record($service, 'provision_requested', 'Khách hàng yêu cầu khởi tạo dịch vụ', [
'order_num' => $order->order_num,
'invoice_number' => $invoice->invoice_number,
]);
}
if ($service->wasRecentlyCreated && $item->product_id) {
if ($item->os_id) {
dispatch(new CreateVirtualMachineJob($item));
} elseif ($isHostingProvision) {
dispatch(new ProvisionHostingAccountJob($item));
}
}
}
}
private function activateRenewal(Invoice $invoice): void
{
$service = Service::query()
->with(['orderItem.product'])
->where('invoice_id', $invoice->id)
->first();
if (! $service) {
return;
}
if ($invoice->order) {
// Same rule as activateOrder(): payment_method/paid_at always mirror the invoice
// that was just paid, regardless of whether the order's status actually changes.
$invoice->order->update([
'status' => OrderStatus::ACTIVE->value,
'payment_method' => $invoice->payment_method,
'paid_at' => $invoice->paid_at,
]);
}
$months = (int) ($invoice->invoiceItems()->where('type', 'renewal')->value('months') ?: $service->orderItem?->months ?: 1);
$baseDate = $service->expires_at && $service->expires_at->isFuture()
? $service->expires_at
: now();
$expiresAt = Carbon::parse($baseDate)->addMonths($months);
$previousExpiresAt = $service->expires_at;
$previousStatus = $service->status;
$wasCancelled = $previousStatus === 'cancelled';
$wasScheduledCancel = (bool) $service->cancel_at_expiry;
$service->update([
'status' => 'active',
'expires_at' => $expiresAt,
'suspended_at' => null,
'cancel_at_expiry' => false,
'cancelled_at' => null,
]);
if ($wasCancelled || $wasScheduledCancel) {
app(ActivityLogger::class)->log(
event: 'cancellation_revoked',
logName: 'Service',
description: "Dịch vụ #{$service->id} được gia hạn nên huỷ bỏ việc hủy dịch vụ"
.($wasCancelled ? ' (đã hủy → hoạt động lại)' : ' (hủy cuối kỳ)'),
subject: $service,
oldValues: ['status' => $previousStatus, 'cancel_at_expiry' => $wasScheduledCancel],
newValues: ['status' => 'active', 'cancel_at_expiry' => false, 'invoice_number' => $invoice->invoice_number],
);
}
app(ActivityLogger::class)->log(
event: 'renewed',
logName: 'Service',
description: "Gia hạn dịch vụ #{$service->id} thêm {$months} tháng (hóa đơn #{$invoice->invoice_number})",
subject: $service,
oldValues: ['expires_at' => $previousExpiresAt?->toDateTimeString()],
newValues: [
'expires_at' => $expiresAt->toDateTimeString(),
'months' => $months,
'invoice_number' => $invoice->invoice_number,
'amount' => (int) $invoice->total,
'payment_method' => $invoice->payment_method,
],
);
if ($service->vm_id) {
dispatch(new VMJobs((int) $service->vm_id, 'Start', false))->delay(now()->addSeconds(5));
}
$this->notifyAdminsOfRenewal($service, $invoice, $months, $previousExpiresAt);
}
private function notifyAdminsOfRenewal(Service $service, Invoice $invoice, int $months, ?CarbonInterface $previousExpiresAt): void
{
if (auth()->guard('admin')->check()) {
return;
}
$source = auth()->guard('client')->check()
? CustomerRenewalNotifier::SOURCE_CUSTOMER
: CustomerRenewalNotifier::SOURCE_SYSTEM;
DB::afterCommit(fn () => app(CustomerRenewalNotifier::class)->notify($service->fresh(), $invoice, $months, $previousExpiresAt, $source));
}
/**
* Merges the purchased addons into order_items.addons_meta (so RenewalPricing charges
* them on the next renewal) and applies the new absolute CPU/RAM/disk totals to the VM.
*/
private function activateUpgrade(Invoice $invoice): void
{
$service = Service::query()
->with(['orderItem.product', 'vmware'])
->find($invoice->service_id);
$orderItem = $service?->orderItem;
if (! $service || ! $orderItem) {
return;
}
$upgradeItems = $invoice->invoiceItems()->where('type', 'upgrade')->get();
if ($upgradeItems->isEmpty()) {
return;
}
$meta = collect(is_array($orderItem->addons_meta) ? $orderItem->addons_meta : []);
$purchasedCodes = [];
foreach ($upgradeItems as $item) {
$addon = Addon::find($item->addon_id);
if (! $addon) {
continue;
}
$code = strtolower((string) $addon->code);
$purchasedCodes[] = $code;
$qty = (int) $item->quantity;
$index = $meta->search(fn ($entry) => (int) ($entry['addon_id'] ?? 0) === $addon->id);
$currentQty = $index === false ? 0 : (int) ($meta->get($index)['qty'] ?? 0);
$newQty = $currentQty + $qty;
$entry = [
'addon_id' => $addon->id,
'code' => $code,
'name' => $addon->name,
'qty' => $newQty,
'price' => (float) $addon->price,
'subtotal' => $newQty * (float) $addon->price,
];
if ($index === false) {
$meta->push($entry);
} else {
$meta->put($index, $entry);
}
}
$orderItem->update(['addons_meta' => $meta->values()->all()]);
$moduleVmware = $service->vmware;
if (! $moduleVmware) {
return;
}
$addonQtyByCode = $meta->keyBy(fn ($entry) => $entry['code'] ?? '');
$product = $orderItem->product;
if (array_intersect(['cpu', 'ram'], $purchasedCodes)) {
$totalCpu = (int) ($product?->cpu ?? 0) + (int) ($addonQtyByCode->get('cpu')['qty'] ?? 0);
$totalRam = (int) ($product?->ram ?? 0) + (int) ($addonQtyByCode->get('ram')['qty'] ?? 0);
UpdateVmConfigJob::dispatch(
$moduleVmware->id,
(string) $moduleVmware->vmware_id,
'',
$totalCpu,
$totalRam,
)->afterCommit();
}
if (in_array('disk', $purchasedCodes, true)) {
$totalDisk = (int) ($product?->disk ?? 0) + (int) ($addonQtyByCode->get('disk')['qty'] ?? 0);
$vcenter = $moduleVmware->vCenter ?? $moduleVmware->ipAddress?->host?->datacenter?->vCenter;
if ($vcenter) {
DB::afterCommit(function () use ($vcenter, $moduleVmware, $totalDisk, $invoice) {
try {
$serviceVCenter = new ServiceVCenter();
if (! $serviceVCenter->loginServer($vcenter)) {
throw new \RuntimeException('vCenter login failed');
}
$serviceVCenter->UpgradeDiskVirtualMachine($moduleVmware, $totalDisk);
} catch (\Throwable $e) {
Log::error('activateUpgrade: disk upgrade failed', [
'invoice_id' => $invoice->id,
'module_vmware_id' => $moduleVmware->id,
'total_disk' => $totalDisk,
'error' => $e->getMessage(),
]);
}
});
}
}
}
private function attachServiceAddons(Service $service, OrderItem $item): void
{
if ($service->addons()->exists()) {
return;
}
$product = $item->product;
if ($product) {
foreach (['cpu' => $product->cpu, 'ram' => $product->ram, 'disk' => $product->disk, 'bandwidth' => $product->bandwidth] as $type => $value) {
if ($value !== null && $value !== '') {
ServiceAddon::create([
'service_id' => $service->id,
'addon_type' => $type,
'addon_value' => (string) ($type === 'ram' ? (int) $value * 1024 : $value),
'price' => 0,
]);
}
}
}
$addonsMeta = is_array($item->addons_meta)
? $item->addons_meta
: json_decode($item->addons_meta ?? '[]', true);
foreach ((array) $addonsMeta as $meta) {
$addon = Addon::find($meta['addon_id'] ?? null);
if (!$addon) {
continue;
}
ServiceAddon::create([
'service_id' => $service->id,
'addon_type' => strtolower((string) $addon->code),
'addon_value' => (string) (strtolower((string) $addon->code) === 'ram' ? (int) ($meta['qty'] ?? 1) * 1024 : ($meta['qty'] ?? 1)),
'price' => (int) ($meta['price'] ?? $addon->price),
]);
}
}
}