- 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>
305 lines
12 KiB
PHP
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),
|
|
]);
|
|
}
|
|
}
|
|
}
|