- 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>
239 lines
9.4 KiB
PHP
239 lines
9.4 KiB
PHP
<?php
|
|
|
|
namespace App\Services\Api\Order;
|
|
|
|
use App\Enums\Invoice\InvoiceStatus;
|
|
use App\Enums\Order\OrderStatus;
|
|
use App\Enums\Product\ProductTypeEnums;
|
|
use App\Helpers\OrderHelper;
|
|
use App\Models\Order;
|
|
use App\Models\Product;
|
|
use App\Repositories\Api\Invoice\InvoiceRepositoryInterface;
|
|
use App\Repositories\Api\Order\OrderRepositoryInterface;
|
|
use App\Repositories\Api\Service\ServiceRepository;
|
|
use App\Services\InvoicePaymentService;
|
|
use App\Services\AdminPaymentNotifier;
|
|
use App\Services\Coupon\CouponService;
|
|
use App\Services\Hosting\HostingServerSelector;
|
|
use App\Services\VirtualMachine\VmIpReservationService;
|
|
use App\Support\AddonQuantityValidator;
|
|
use Illuminate\Support\Facades\DB;
|
|
|
|
class OrderService
|
|
{
|
|
public function __construct(
|
|
protected OrderRepositoryInterface $orderRepo,
|
|
protected ServiceRepository $productRepo,
|
|
protected InvoiceRepositoryInterface $invoiceRepo,
|
|
protected InvoicePaymentService $invoicePaymentService,
|
|
protected AdminPaymentNotifier $adminPaymentNotifier,
|
|
protected CouponService $couponService,
|
|
protected VmIpReservationService $vmIpReservation,
|
|
protected HostingServerSelector $hostingServers,
|
|
) {}
|
|
|
|
// =========================================================================
|
|
// create — Step 1: Customer places an order
|
|
// =========================================================================
|
|
|
|
/**
|
|
* Validate → calculate price → persist Order + OrderItem + Invoice.
|
|
*
|
|
* @return array{order: Order, invoice: \App\Models\Invoice}
|
|
* @throws \InvalidArgumentException
|
|
*/
|
|
public function create(array $data, $customer): array
|
|
{
|
|
if ($this->orderRepo->hasPendingOrder($customer->id)) {
|
|
throw new \InvalidArgumentException('Bạn có đơn hàng chưa thanh toán. Vui lòng thanh toán trước khi tạo đơn mới.');
|
|
}
|
|
|
|
['product' => $product, 'base_price' => $basePrice, 'addons_meta' => $addonsMeta, 'sub_total' => $subTotal] = $this->quote($data);
|
|
$couponCode = $data['coupon_code'] ?? null;
|
|
|
|
$this->assertInfrastructureAvailable([$product]);
|
|
|
|
$expectedTotal = $couponCode
|
|
? $subTotal - $this->couponService->preview($couponCode, $customer, $product, $data['billing_cycle'], $subTotal)['discount']
|
|
: $subTotal;
|
|
|
|
if ($customer->total < $expectedTotal) {
|
|
throw new \InvalidArgumentException(sprintf(
|
|
'Số dư không đủ. Hiện có: %s VNĐ, cần: %s VNĐ.',
|
|
number_format($customer->total),
|
|
number_format($expectedTotal)
|
|
));
|
|
}
|
|
|
|
return DB::transaction(function () use ($data, $customer, $product, $basePrice, $subTotal, $addonsMeta, $couponCode) {
|
|
$applied = $couponCode
|
|
? $this->couponService->lockAndApply($couponCode, $customer, $product, $data['billing_cycle'], $subTotal)
|
|
: null;
|
|
$discount = $applied['discount'] ?? 0;
|
|
|
|
$order = $this->orderRepo->create([
|
|
'order_num' => OrderHelper::generateOrderNumber(),
|
|
'customer_id' => $customer->id,
|
|
'sub_total' => $subTotal,
|
|
'discount_amount' => $discount,
|
|
'coupon_id' => $applied['coupon']->id ?? null,
|
|
'coupon_code' => $applied['coupon']->code ?? null,
|
|
'total' => $subTotal - $discount,
|
|
'status' => OrderStatus::PENDING->value,
|
|
'client_ip' => request()?->ip(),
|
|
]);
|
|
|
|
if ($applied) {
|
|
$this->couponService->record($applied['coupon'], $order, $discount);
|
|
}
|
|
|
|
$this->orderRepo->createItem($order->id, [
|
|
'product_id' => $product->id,
|
|
'billing_cycle' => $data['billing_cycle'],
|
|
'months' => OrderHelper::cycleToMonths($data['billing_cycle']),
|
|
'server_name' => $data['server_name'] ?? null,
|
|
'os_id' => $data['os_id'] ?? null,
|
|
'quantity' => 1,
|
|
'price' => $basePrice,
|
|
'addons' => $addonsMeta, // serialised to addons_meta JSON
|
|
]);
|
|
|
|
// Reload items so invoice repo can iterate them
|
|
$order->load('orderItems.product');
|
|
|
|
$invoice = $this->invoiceRepo->createFromOrder($order);
|
|
|
|
return ['order' => $order, 'invoice' => $invoice];
|
|
});
|
|
}
|
|
|
|
private function assertInfrastructureAvailable(iterable $products): void
|
|
{
|
|
$needsVm = collect($products)->contains(fn ($product) => in_array(
|
|
ProductTypeEnums::tryFrom($product?->category?->type ?? ''),
|
|
[ProductTypeEnums::VPS, ProductTypeEnums::CLOUD],
|
|
true
|
|
));
|
|
|
|
if ($needsVm && !$this->vmIpReservation->hasFreeIp()) {
|
|
throw new \InvalidArgumentException('Hiện tại hạ tầng đang đầy vui lòng liên hệ quản trị viên');
|
|
}
|
|
|
|
$this->assertHostingServerAvailable($products);
|
|
}
|
|
|
|
private function assertHostingServerAvailable(iterable $products): void
|
|
{
|
|
foreach ($products as $product) {
|
|
if ($product?->product_type !== 'hosting' || ! $product->servicePlan) {
|
|
continue;
|
|
}
|
|
|
|
if (! $this->hostingServers->hasHealthyServer($product->servicePlan)) {
|
|
throw new \InvalidArgumentException('Dịch vụ hosting đang bảo trì, vui lòng thử lại sau hoặc liên hệ quản trị viên.');
|
|
}
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Giá niêm yết (chu kỳ + addon) tính phía server; nguồn duy nhất cho create() và preview mã giảm giá.
|
|
*
|
|
* @return array{product: Product, base_price: int, addons_meta: array, sub_total: int}
|
|
* @throws \InvalidArgumentException
|
|
*/
|
|
public function quote(array $data): array
|
|
{
|
|
$product = $this->productRepo->find($data['product_id']);
|
|
if (!$product) {
|
|
throw new \InvalidArgumentException('Sản phẩm không tồn tại.');
|
|
}
|
|
|
|
$basePrice = $this->productRepo->getPricing($product->id, $data['billing_cycle']);
|
|
if (is_null($basePrice)) {
|
|
throw new \InvalidArgumentException('Không tìm thấy giá cho chu kỳ thanh toán này.');
|
|
}
|
|
|
|
$addonsMeta = $this->resolveAddons($data['addons'] ?? [], $product);
|
|
|
|
return [
|
|
'product' => $product,
|
|
'base_price' => $basePrice,
|
|
'addons_meta' => $addonsMeta,
|
|
'sub_total' => $basePrice + array_sum(array_column($addonsMeta, 'subtotal')),
|
|
];
|
|
}
|
|
|
|
// =========================================================================
|
|
// pay — Step 2: Customer pays the pending order
|
|
// =========================================================================
|
|
|
|
/**
|
|
* Debit customer balance → mark Order+Invoice paid → provision Service records.
|
|
*
|
|
* @throws \InvalidArgumentException
|
|
*/
|
|
public function pay(Order $order, string $method): Order
|
|
{
|
|
if (in_array($order->status, [OrderStatus::ACTIVE->value, OrderStatus::PROCESSING->value], true)) {
|
|
throw new \InvalidArgumentException('Đơn hàng đã được thanh toán hoặc đang xử lý.');
|
|
}
|
|
|
|
$this->assertInfrastructureAvailable($order->orderItems->map->product);
|
|
|
|
$processedOrder = DB::transaction(function () use ($order, $method) {
|
|
$customer = $order->customer;
|
|
|
|
if ($customer->total < $order->total) {
|
|
throw new \InvalidArgumentException(sprintf(
|
|
'Số dư không đủ. Hiện có: %s VNĐ, cần: %s VNĐ.',
|
|
number_format($customer->total),
|
|
number_format($order->total)
|
|
));
|
|
}
|
|
|
|
$invoice = $order->invoice;
|
|
if (! $invoice) {
|
|
throw new \InvalidArgumentException('Không tìm thấy hóa đơn của đơn hàng.');
|
|
}
|
|
|
|
if ((int) $invoice->status === InvoiceStatus::PAID->value) {
|
|
throw new \InvalidArgumentException('Đơn hàng đã được thanh toán hoặc đang xử lý.');
|
|
}
|
|
|
|
if ($order->status === OrderStatus::CANCELLED->value) {
|
|
$order->update(['status' => OrderStatus::PENDING->value]);
|
|
}
|
|
|
|
$paidInvoice = $this->invoicePaymentService->payWithBalance($invoice, $customer, $method);
|
|
$processedOrder = $order->fresh(['orderItems', 'invoice', 'customer']);
|
|
|
|
return $processedOrder;
|
|
});
|
|
|
|
$this->adminPaymentNotifier->notify($processedOrder->invoice, (int) $processedOrder->total, 'order');
|
|
|
|
return $processedOrder;
|
|
}
|
|
|
|
|
|
// =========================================================================
|
|
// Helpers
|
|
// =========================================================================
|
|
|
|
|
|
/**
|
|
* Enrich raw addon entries with resolved price & subtotal.
|
|
*
|
|
* Input: [['addon_id' => 1, 'qty' => 2], ...]
|
|
* Output: [['addon_id' => 1, 'qty' => 2, 'price' => 50000, 'subtotal' => 100000], ...]
|
|
*
|
|
* @throws \Illuminate\Validation\ValidationException
|
|
*/
|
|
private function resolveAddons(array $raw, Product $product): array
|
|
{
|
|
$activeAddons = $product->addons()->where('status', true)->get()->keyBy('id');
|
|
|
|
return AddonQuantityValidator::resolve($raw, $activeAddons);
|
|
}
|
|
}
|