feat: nạp tiền thủ công mã 30 phút, gia hạn trừ số dư, đồng bộ domain từ Plesk
- Nạp tiền: mã thanh toán sống 30 phút (ManualDepositDraft), khôi phục/huỷ mã, lệnh dọn mã hết hạn; total_pending chỉ tính giao dịch đã nộp chứng từ, admin duyệt/từ chối/xoá giao dịch - Gia hạn dịch vụ: phát hành invoice gia hạn trên order gốc và trừ số dư ngay - Đồng bộ domain từ Plesk theo server, ServiceOrderRecorder tạo order/invoice cho dịch vụ admin dựng - Danh sách dịch vụ trả field theo loại (service_plan, dns_records_count), đếm dịch vụ theo loại Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5.5
parent
10af41c5cf
commit
c5115b52dd
@@ -8,7 +8,9 @@ use App\DTOs\Order\ClientOrderFilters;
|
||||
use App\Enums\Invoice\InvoiceStatus;
|
||||
use App\Helpers\OrderHelper;
|
||||
use App\Jobs\VirtualMachine\Provision\RetryProvisioningOrchestrator;
|
||||
use App\Models\Customer;
|
||||
use App\Models\Invoice;
|
||||
use App\Models\InvoiceItem;
|
||||
use App\Models\ModuleVmware;
|
||||
use App\Models\Order;
|
||||
use App\Models\OrderItem;
|
||||
@@ -16,6 +18,7 @@ use App\Models\Service;
|
||||
use App\Models\VirtualMachines;
|
||||
use App\Repositories\Api\Order\OrderRepositoryInterface;
|
||||
use App\Repositories\Api\Service\ServiceRepositoryInterface;
|
||||
use App\Services\InvoicePaymentService;
|
||||
use App\Support\RenewalPricing;
|
||||
use Carbon\Carbon;
|
||||
use Illuminate\Contracts\Pagination\LengthAwarePaginator;
|
||||
@@ -26,6 +29,7 @@ class OrderQueryService
|
||||
public function __construct(
|
||||
private readonly OrderRepositoryInterface $orderRepository,
|
||||
private readonly ServiceRepositoryInterface $productRepo,
|
||||
private readonly InvoicePaymentService $invoicePaymentService,
|
||||
) {
|
||||
}
|
||||
|
||||
@@ -166,12 +170,12 @@ class OrderQueryService
|
||||
return ['ok' => true, 'message' => 'Đã yêu cầu khởi tạo lại dịch vụ. Hệ thống sẽ kiểm tra và xử lý trong vài phút.'];
|
||||
}
|
||||
|
||||
public function renewService(int $serviceId, int $customerId, int $months): array
|
||||
public function renewService(int $serviceId, Customer $customer, int $months): array
|
||||
{
|
||||
$service = Service::query()
|
||||
->with(['orderItem.product', 'invoice'])
|
||||
->with(['orderItem.product'])
|
||||
->where('id', $serviceId)
|
||||
->where('customer_id', $customerId)
|
||||
->where('customer_id', $customer->id)
|
||||
->whereNotIn('status', ['terminated', 'cancelled'])
|
||||
->first();
|
||||
|
||||
@@ -197,89 +201,114 @@ class OrderQueryService
|
||||
|
||||
$cycles = intdiv($months, $cycleMonths);
|
||||
|
||||
// Gia hạn luôn dùng giá sản phẩm hiện tại trong bảng giá, không dùng giá đã
|
||||
// chốt lúc đặt mua ban đầu — giá cũ chỉ để hiển thị trên đơn hàng/hoá đơn gốc.
|
||||
$currentPrice = $this->productRepo->getPricing((int) $service->product_id, $orderItem->billing_cycle);
|
||||
$currentPrice = $this->productRepo->getPricing((int) ($service->product_id ?: $orderItem->product_id), $orderItem->billing_cycle);
|
||||
if ($currentPrice === null) {
|
||||
return ['ok' => false, 'message' => 'Sản phẩm hiện không còn bán, không thể gia hạn.', 'code' => 400];
|
||||
}
|
||||
|
||||
$renewalPrice = (int) $currentPrice * $cycles;
|
||||
$addonTotal = RenewalPricing::addonTotal($service, $cycles);
|
||||
$price = $renewalPrice + $addonTotal;
|
||||
|
||||
if ($service->invoice_id && Invoice::where('id', $service->invoice_id)
|
||||
->where('invoice_type', 2)
|
||||
->whereIn('status', [InvoiceStatus::UNPAID->value, InvoiceStatus::OVERDUE->value])
|
||||
->exists()) {
|
||||
$invoice = Invoice::find($service->invoice_id);
|
||||
$invoice = DB::transaction(function () use ($service, $orderItem, $renewalPrice, $cycles, $months) {
|
||||
$service = Service::query()->whereKey($service->id)->lockForUpdate()->first();
|
||||
$pending = $this->pendingRenewalInvoice($service);
|
||||
|
||||
if ($pending && ((int) $pending->amount_paid > 0 || $this->renewalMonths($pending) === $months)) {
|
||||
return $pending;
|
||||
}
|
||||
|
||||
return $this->issueRenewalInvoice($service, $orderItem, $renewalPrice, $cycles, $months, $pending);
|
||||
});
|
||||
|
||||
try {
|
||||
$invoice = $this->invoicePaymentService->payWithBalance($invoice, $customer);
|
||||
} catch (\InvalidArgumentException $e) {
|
||||
return [
|
||||
'ok' => true,
|
||||
'message' => 'Dịch vụ đã có hóa đơn gia hạn đang chờ thanh toán.',
|
||||
'data' => [
|
||||
'order_id' => $invoice?->order_id,
|
||||
'invoice_id' => $invoice?->id,
|
||||
'invoice_number' => $invoice?->invoice_number,
|
||||
'total' => (float) ($invoice?->total ?? 0),
|
||||
'status' => $invoice?->status,
|
||||
'payment_url' => $invoice?->clientPath(),
|
||||
],
|
||||
'ok' => false,
|
||||
'message' => $e->getMessage() . ' Hóa đơn gia hạn #' . $invoice->invoice_number . ' đang chờ thanh toán.',
|
||||
'code' => 422,
|
||||
'data' => $this->renewalPayload($invoice),
|
||||
];
|
||||
}
|
||||
|
||||
// Gia hạn là một billing cycle mới của CÙNG một Order/Service — không tạo Order mới.
|
||||
// Order gốc chính là order đã tạo ra service này (qua order_item -> order_id).
|
||||
$periodStart = $service->expires_at && $service->expires_at->isFuture() ? $service->expires_at->copy() : now();
|
||||
$periodEnd = $periodStart->copy()->addMonths($months);
|
||||
|
||||
$invoice = DB::transaction(function () use ($service, $orderItem, $price, $renewalPrice, $cycles, $months, $periodStart, $periodEnd) {
|
||||
$invoice = Invoice::create([
|
||||
'invoice_number' => OrderHelper::generateOrderNumber('INV'),
|
||||
'customer_id' => $service->customer_id,
|
||||
'order_id' => $orderItem->order_id,
|
||||
'invoice_type' => 2,
|
||||
'sub_total' => $price,
|
||||
'discount_amount' => 0,
|
||||
'tax' => 0,
|
||||
'total' => $price,
|
||||
'status' => InvoiceStatus::UNPAID->value,
|
||||
'due_date' => now()->addDays(7)->toDateString(),
|
||||
'billing_period_start' => $periodStart->toDateString(),
|
||||
'billing_period_end' => $periodEnd->toDateString(),
|
||||
]);
|
||||
|
||||
\App\Models\InvoiceItem::create([
|
||||
'invoice_id' => $invoice->id,
|
||||
'product_id' => $service->product_id,
|
||||
'type' => 'renewal',
|
||||
'description' => 'Gia hạn dịch vụ: ' . ($orderItem->product?->name ?? $service->server_name ?? ''),
|
||||
'months' => $months,
|
||||
'quantity' => 1,
|
||||
'unit_price' => $renewalPrice,
|
||||
'total_price' => $renewalPrice,
|
||||
]);
|
||||
|
||||
foreach (RenewalPricing::addonInvoiceItems($service, $cycles, $months) as $addonItem) {
|
||||
\App\Models\InvoiceItem::create($addonItem + ['invoice_id' => $invoice->id]);
|
||||
}
|
||||
|
||||
$service->update(['invoice_id' => $invoice->id]);
|
||||
|
||||
return $invoice;
|
||||
});
|
||||
|
||||
return [
|
||||
'ok' => true,
|
||||
'message' => 'Đã tạo hóa đơn gia hạn. Vui lòng thanh toán để gia hạn dịch vụ.',
|
||||
'data' => [
|
||||
'order_id' => $invoice->order_id,
|
||||
'invoice_id' => $invoice->id,
|
||||
'invoice_number' => $invoice->invoice_number,
|
||||
'total' => (float) $invoice->total,
|
||||
'status' => $invoice->status,
|
||||
'payment_url' => $invoice->clientPath(),
|
||||
'message' => 'Gia hạn dịch vụ thành công.',
|
||||
'data' => $this->renewalPayload($invoice) + [
|
||||
'expires_at' => $service->fresh()->expires_at?->toISOString(),
|
||||
],
|
||||
];
|
||||
}
|
||||
|
||||
private function pendingRenewalInvoice(Service $service): ?Invoice
|
||||
{
|
||||
if (! $service->invoice_id) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return Invoice::query()
|
||||
->where('id', $service->invoice_id)
|
||||
->where('invoice_type', 2)
|
||||
->whereIn('status', [InvoiceStatus::UNPAID->value, InvoiceStatus::OVERDUE->value])
|
||||
->first();
|
||||
}
|
||||
|
||||
private function renewalMonths(Invoice $invoice): int
|
||||
{
|
||||
return (int) $invoice->invoiceItems()->where('type', 'renewal')->value('months');
|
||||
}
|
||||
|
||||
private function issueRenewalInvoice(Service $service, OrderItem $orderItem, int $renewalPrice, int $cycles, int $months, ?Invoice $draft): Invoice
|
||||
{
|
||||
$price = $renewalPrice + RenewalPricing::addonTotal($service, $cycles);
|
||||
$periodStart = $service->expires_at && $service->expires_at->isFuture() ? $service->expires_at->copy() : now();
|
||||
|
||||
$invoice = $draft ?? new Invoice([
|
||||
'invoice_number' => OrderHelper::generateOrderNumber('INV'),
|
||||
'status' => InvoiceStatus::UNPAID->value,
|
||||
]);
|
||||
$invoice->invoiceItems()->delete();
|
||||
$invoice->fill([
|
||||
'customer_id' => $service->customer_id,
|
||||
'order_id' => $orderItem->order_id,
|
||||
'invoice_type' => 2,
|
||||
'sub_total' => $price,
|
||||
'discount_amount' => 0,
|
||||
'tax' => 0,
|
||||
'total' => $price,
|
||||
'due_date' => now()->addDays(7)->toDateString(),
|
||||
'billing_period_start' => $periodStart->toDateString(),
|
||||
'billing_period_end' => $periodStart->copy()->addMonths($months)->toDateString(),
|
||||
])->save();
|
||||
|
||||
InvoiceItem::create([
|
||||
'invoice_id' => $invoice->id,
|
||||
'product_id' => $service->product_id ?: $orderItem->product_id,
|
||||
'type' => 'renewal',
|
||||
'description' => 'Gia hạn dịch vụ: ' . ($orderItem->product?->name ?? $service->server_name ?? ''),
|
||||
'months' => $months,
|
||||
'quantity' => 1,
|
||||
'unit_price' => $renewalPrice,
|
||||
'total_price' => $renewalPrice,
|
||||
]);
|
||||
|
||||
foreach (RenewalPricing::addonInvoiceItems($service, $cycles, $months) as $addonItem) {
|
||||
InvoiceItem::create($addonItem + ['invoice_id' => $invoice->id]);
|
||||
}
|
||||
|
||||
$service->update(['invoice_id' => $invoice->id]);
|
||||
|
||||
return $invoice;
|
||||
}
|
||||
|
||||
private function renewalPayload(Invoice $invoice): array
|
||||
{
|
||||
return [
|
||||
'order_id' => $invoice->order_id,
|
||||
'invoice_id' => $invoice->id,
|
||||
'invoice_number' => $invoice->invoice_number,
|
||||
'total' => (float) $invoice->total,
|
||||
'status' => $invoice->status,
|
||||
'payment_url' => $invoice->clientPath(),
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user