diff --git a/app/Enums/Product/ProductBillingCycle.php b/app/Enums/Product/ProductBillingCycle.php
index 02c6be9..5bc1120 100644
--- a/app/Enums/Product/ProductBillingCycle.php
+++ b/app/Enums/Product/ProductBillingCycle.php
@@ -37,4 +37,21 @@ enum ProductBillingCycle: string
self::TRIENNIALLY => 36,
};
}
+
+ /**
+ * Resolve from the legacy admin integer billing code:
+ * 2 → monthly, 3 → quarterly, 4 → semi_annually, 5 → annually
+ */
+ public static function fromLegacyCode(int $code): self
+ {
+ return match ($code) {
+ 2 => self::MONTHLY,
+ 3 => self::QUARTERLY,
+ 4 => self::SEMI_ANNUALLY,
+ 5 => self::ANNUALLY,
+ 6 => self::BIENNIALLY,
+ 7 => self::TRIENNIALLY,
+ default => self::MONTHLY,
+ };
+ }
}
diff --git a/app/Helpers/OrderHelper.php b/app/Helpers/OrderHelper.php
index e44e24f..60f9c53 100644
--- a/app/Helpers/OrderHelper.php
+++ b/app/Helpers/OrderHelper.php
@@ -2,32 +2,32 @@
namespace App\Helpers;
-use Illuminate\Support\Str;
+use App\Models\Invoice;
use App\Models\Order;
+use Illuminate\Support\Str;
class OrderHelper
{
/**
- * Generate a unique order number with prefix.
+ * Generate a unique prefixed number.
*
- * @param string $prefix
- * @return string
+ * Examples:
+ * generateOrderNumber() → ORD-A1B2C3D4
+ * generateOrderNumber('INV') → INV-A1B2C3D4
+ *
+ * Uniqueness is guaranteed by checking the correct column on each model.
*/
public static function generateOrderNumber(string $prefix = 'ORD'): string
{
do {
- // Generate a random string of 8 uppercase characters/numbers
- $random = strtoupper(Str::random(8));
- $orderNum = $prefix . '-' . $random;
-
- if ($prefix === 'INV') {
- $exists = \App\Models\Invoice::where('order_num', $orderNum)->exists();
- } else {
- $exists = Order::where('order_num', $orderNum)->exists();
- }
-
+ $number = $prefix . '-' . strtoupper(Str::random(8));
+
+ $exists = match ($prefix) {
+ 'INV' => Invoice::where('invoice_num', $number)->exists(),
+ default => Order::where('order_num', $number)->exists(),
+ };
} while ($exists);
- return $orderNum;
+ return $number;
}
}
diff --git a/app/Http/Controllers/Api/Admin/Invoice/InvoiceController.php b/app/Http/Controllers/Api/Admin/Invoice/InvoiceController.php
index 4583361..61bad8f 100644
--- a/app/Http/Controllers/Api/Admin/Invoice/InvoiceController.php
+++ b/app/Http/Controllers/Api/Admin/Invoice/InvoiceController.php
@@ -73,7 +73,7 @@ class InvoiceController extends Controller
{
$validated = $request->validate([
'customer_id' => 'required|numeric',
- 'payment_id' => 'required|numeric',
+ 'payment_method' => 'required|numeric',
'sub_total' => 'required|numeric',
'total' => 'required|numeric',
]);
@@ -84,7 +84,7 @@ class InvoiceController extends Controller
'sub_total' => $validated['sub_total'],
'tax' => $validated['sub_total'] * 0.1,
'total' => $validated['total'],
- 'payment_id' => $validated['payment_id'],
+ 'payment_method' => $validated['payment_method'],
'customer_id' => $validated['customer_id'],
'status' => 0,
];
diff --git a/app/Http/Controllers/Api/Admin/Order/OrderController.php b/app/Http/Controllers/Api/Admin/Order/OrderController.php
index 2739d32..d9f00c6 100644
--- a/app/Http/Controllers/Api/Admin/Order/OrderController.php
+++ b/app/Http/Controllers/Api/Admin/Order/OrderController.php
@@ -3,7 +3,6 @@
namespace App\Http\Controllers\Api\Admin\Order;
use App\Http\Controllers\Controller;
-use App\Http\Helpers\Helper;
use App\Http\Requests\OrderRequest;
use App\Http\Requests\UpdateOrderRequest;
use App\Http\Resources\Api\Admin\OrderResource;
@@ -12,7 +11,6 @@ use App\Jobs\VirtualMachine\Create\CreateVMJobs;
use App\Jobs\VirtualMachine\Upgrade\UpgradeVirtualMachine;
use App\Models\Order;
use App\Repositories\Addon\AddonRepositoryInterface;
-use App\Repositories\Hosting\HostingRepositoryInterface;
use App\Repositories\Invoice\InvoiceRepositoryInterface;
use App\Repositories\InvoiceItem\InvoiceItemRepositoryInterface;
use App\Repositories\Module\Hosting\HostingRepositoryInterface as ModuleHostingRepositoryInterface;
@@ -21,23 +19,16 @@ use App\Repositories\Module\IpAddress\IpAddressRepositoryInterface;
use App\Repositories\Module\Vmware\VmwareRepositoryInterface;
use App\Repositories\Order\OrderRepositoryInterface;
use App\Repositories\Os\OsRepositoryInterface;
-use App\Repositories\VPS\VPSRepositoryInterface;
-use App\Models\Customer;
-use Carbon\Carbon;
+use App\Services\OrderService;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
-use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Storage;
-use Illuminate\Support\Facades\Validator;
-use Yajra\DataTables\Facades\DataTables;
class OrderController extends Controller
{
public function __construct(
private readonly OrderRepositoryInterface $orderRepository,
- private readonly HostingRepositoryInterface $hostingRepository,
- private readonly VPSRepositoryInterface $vPSRepository,
private readonly InvoiceRepositoryInterface $invoiceRepository,
private readonly InvoiceItemRepositoryInterface $invoiceItemRepository,
private readonly ModuleHostingRepositoryInterface $moduleHostingRepository,
@@ -46,287 +37,62 @@ class OrderController extends Controller
private readonly OsRepositoryInterface $osRepository,
private readonly IpAddressRepositoryInterface $ipAddressRepository,
private readonly AddonRepositoryInterface $addonRepository,
+ private readonly OrderService $orderService,
) {}
public function index(Request $request): JsonResponse
{
$perPage = min((int) $request->get('per_page', 15), 100);
-
- $query = $this->orderRepository->filter(
+
+ $data = $this->orderRepository->filter(
$request->all(),
['payment', 'customer', 'invoice'],
[],
['*'],
true
- );
+ )->paginate($perPage);
return response()->json([
'error' => false,
'data' => [
- 'items' => OrderResource::collection($query->get())->resolve(),
- 'total' => $query->count(),
- 'per_page' => $perPage,
+ 'items' => OrderResource::collection($data->items())->resolve(),
+ 'total' => $data->total(),
+ 'per_page' => $data->perPage(),
],
]);
}
- public function datatable(Request $request): JsonResponse
- {
- $query = $this->orderRepository->filter(
- $request->all(),
- ['payment', 'customer', 'invoice'],
- [],
- ['*'],
- true
- );
-
- return DataTables::eloquent($query)
- ->addColumn('order_num', fn($d) => $d->order_num)
- ->addColumn('date', fn($d) => $d->date)
- ->addColumn('customer', fn($d) => $d->customer?->fullname)
- ->addColumn('payment', fn($d) => $d->payment?->name)
- ->addColumn('amount', fn($d) => $d->amount)
- ->addColumn('invoice_status', fn($d) => $d->invoice?->status)
- ->addColumn('status', fn($d) => $d->status)
- ->addColumn('action', fn($d) => $d->id)
- ->rawColumns(['order_num', 'date', 'customer', 'payment', 'amount', 'invoice_status', 'status', 'action'])
- ->make(true);
- }
-
public function store(OrderRequest $request): JsonResponse
{
- $request->validated();
-
- $customer = Customer::find($request->customer_id);
- if (!$customer || (int) $customer->status !== 1) {
- return response()->json([
- 'error' => true,
- 'message' => 'Tài khoản khách hàng không hoạt động. Không thể tạo đơn hàng.',
- ], 422);
- }
-
- $total = 0;
- $product_item = [];
- $relid_data = [];
-
- foreach ($request->product as $key => $item) {
- $product = \App\Models\Product::with('category')->find($item['product_id']);
-
- if (!$product) {
- return response()->json([
- 'error' => true,
- 'message' => "Sản phẩm không tồn tại (ID: {$item['product_id']})."
- ], 422);
- }
-
- // Derive legacy numeric type from product_type for module creation
- $moduleType = match ($product->product_type) {
- 'hosting' => 3,
- 'vps' => 4,
- default => 0,
- };
- $relid_data[$key]['type'] = $moduleType;
-
- $message = $product->name;
- $relid_data[$key]['data'] = [
- 'customer_id' => $request->customer_id,
- 'status' => 0,
- 'name' => $item['domain'] ?? $product->name,
- ];
-
- $billingDetails = $this->calculateBillingDetails($item['billing']);
- $relid_data[$key]['data'] = array_merge($relid_data[$key]['data'], $billingDetails);
-
- $price_select = $item['price_custom'] ?? $product->price_sale ?? $product->price;
- $relid_data[$key]['data']['first_payment'] = $price_select * $billingDetails['billing'];
- $relid_data[$key]['data']['next_payment'] = ($item['price_custom'] ?? $product->price) * $billingDetails['billing'];
-
- $generalCode = Helper::generateUniqueCode();
-
- $billingCycle = match ((int) $item['billing']) {
- 2 => 'monthly',
- 3 => 'quarterly',
- 4 => 'semi_annually',
- 5 => 'annually',
- default => 'monthly',
- };
-
- $product_item[$key] = [
- 'product_id' => $product->id,
- 'product_type' => $product->product_type,
- 'months' => $billingDetails['billing'],
- 'billing_cycle' => $billingCycle,
- 'os_id' => $item['os_id'] ?? null,
- 'quantity' => (int) ($item['quantity'] ?? 1),
- 'server_name' => $item['domain'] ?? null,
- 'price' => (int) $price_select,
- 'invoice_type' => 1,
- 'code' => $generalCode,
- ];
-
- $relid_data[$key]['code'] = $generalCode;
-
- if ($item['quantity'] > 1) {
- for ($i = 1; $i < $item['quantity']; $i++) {
- $newCode = Helper::generateUniqueCode();
- $product_item[] = array_merge($product_item[$key], ['code' => $newCode]);
- $relid_data[] = array_merge($relid_data[$key], ['code' => $newCode]);
- }
- }
-
- $total += $item['quantity'] * ($item['price_custom'] ?? ($product->price_sale ?? $product->price)) * $billingDetails['billing'];
- }
-
- $paymentRecord = \App\Models\Payment::find((int) $request->payment_method);
- $isBankTransfer = $paymentRecord && (int) $paymentRecord->type === 1;
-
- $order_item = [
- 'order_num' => Helper::generateUniqueCode(),
- 'customer_id' => $request->customer_id,
- 'invoice_id' => 0,
- 'discount_id' => $request->discount_id ?: null,
- 'amount' => $total + ($total * (10 / 100)),
- 'payment_id' => $paymentRecord?->id,
- 'payment_proof' => $request->payment_proof ?? null,
- 'user_id' => Auth::guard('admin')->id(),
- 'status' => $request->status,
- 'client_ip' => $request->getClientIp(),
- ];
-
- $invoice_data = [
- 'date' => Carbon::today()->toDateString(),
- 'due_date' => Carbon::today()->toDateString(),
- 'date_paid' => (int)$request->status === 1 ? Carbon::today()->toDateString() : null,
- 'sub_total' => $total,
- 'tax' => $total * (10 / 100),
- 'total' => $total + ($total * (10 / 100)),
- 'payment_id' => $paymentRecord?->id,
- 'customer_id' => $request->create_invoice ? $request->customer_id : 0,
- 'status' => $request->status
- ];
-
- DB::beginTransaction();
-
try {
- $invoice = $this->invoiceRepository->create($invoice_data);
-
- $product_item = array_map(function ($array) use ($invoice) {
- $array['invoice_id'] = $invoice->id;
- return $array;
- }, $product_item);
-
- if ($request->create_invoice) {
- $order_item['invoice_id'] = $invoice->id;
- }
-
- $order = $this->orderRepository->create($order_item);
-
- $relid_data = array_map(function ($array) use ($order) {
- $array['data']['order_id'] = $order->id;
- return $array;
- }, $relid_data);
-
- $relid = [];
-
- foreach ($relid_data as $item) {
- $code = $item['code'];
-
- switch ($item['type']) {
- case 3:
- $relid_id = $this->moduleHostingRepository->create($item['data']);
- $relid[$code] = $relid_id->id;
- break;
- case 4:
- $item['data']['status'] = 2;
- $relid_id = $this->moduleVmwareRepository->create($item['data']);
- $relid[$code] = $relid_id->id;
- break;
- }
- }
-
- $product_item = array_map(function ($array) {
- unset($array['code']);
- return $array;
- }, $product_item);
-
- $invoiceItemData = array_map(function ($item) {
- return \Illuminate\Support\Arr::except($item, ['billing_cycle']);
- }, $product_item);
-
- $this->invoiceItemRepository->insert($invoiceItemData);
-
- $now = now();
- \App\Models\OrderItem::insert(array_map(function ($item) use ($now) {
- $expiryDate = $now->copy()->addMonths($item['months']);
- return [
- 'order_id' => $order->id,
- 'product_id' => $item['product_id'],
- 'billing_cycle' => $item['billing_cycle'],
- 'months' => $item['months'],
- 'expiry_date' => $expiryDate,
- 'os_id' => $item['os_id'],
- 'quantity' => $item['quantity'],
- 'server_name' => $item['server_name'],
- 'price' => $item['price'],
- 'created_at' => $now,
- 'updated_at' => $now,
- ];
- }, $product_item));
-
- // Dispatch VM creation job for VPS/cloud products
- $vpsTypes = ['vps', 'cloud'];
- $createdOrderItems = \App\Models\OrderItem::where('order_id', $order->id)->get();
- foreach ($createdOrderItems as $orderItem) {
- if (in_array($orderItem->product?->product_type, $vpsTypes)) {
- \App\Jobs\VirtualMachine\Create\CreateVirtualMachineJob::dispatch($orderItem)
- ->delay(now()->addSeconds(10));
- }
- }
-
- // Deduct customer balance for non-bank-transfer payments
- if (!$isBankTransfer) {
- Customer::where('id', $request->customer_id)
- ->decrement('total', (int) ($total + ($total * (10 / 100))));
- }
-
- DB::commit();
+ $order = $this->orderService->createOrder($request->validated());
return response()->json([
'error' => false,
'message' => 'Order created successfully',
- 'data' => new OrderResource($order)
+ 'data' => new OrderResource($order),
], 201);
+ } catch (\InvalidArgumentException $e) {
+ return response()->json(['error' => true, 'message' => $e->getMessage()], 422);
} catch (\Exception $e) {
- DB::rollBack();
- return response()->json([
- 'error' => true,
- 'message' => $e->getMessage()
- ], 500);
+ return response()->json(['error' => true, 'message' => $e->getMessage()], 500);
}
}
- public function show(int $id): JsonResponse
+ public function show(string $order_num): JsonResponse
{
- $order = Order::with([
- 'orderItems.product',
- 'orderItems.os',
- 'user',
- 'customer',
- 'payment',
- 'discount',
- 'invoice'
- ])->find($id);
+ $order = $this->orderRepository->findByOrderNum($order_num);
if (!$order) {
return response()->json([
'error' => true,
- 'message' => "Can't find order by ID #" . $id
+ 'message' => "Can't find order by ID #" . $order_num,
], 404);
}
return response()->json([
'error' => false,
- 'data' => new OrderResource($order)
+ 'data' => new OrderResource($order),
]);
}
@@ -337,7 +103,7 @@ class OrderController extends Controller
if (!$order) {
return response()->json([
'error' => true,
- 'message' => "Can't find order by ID #" . $id
+ 'message' => "Can't find order by ID #" . $id,
], 404);
}
@@ -389,16 +155,14 @@ class OrderController extends Controller
$ip_address = $this->ipAddressRepository->filter([
'module_host_id' => $os->osGroup->module_host_id,
- 'status' => 0
+ 'status' => 0,
]);
if (count($ip_address) > 0) {
$this->moduleVmwareRepository->update($item->relid, [
'module_ipaddress_id' => $ip_address->first()->id,
]);
- $this->ipAddressRepository->update($ip_address->first()->id, [
- 'status' => 1
- ]);
+ $this->ipAddressRepository->update($ip_address->first()->id, ['status' => 1]);
$jobs[] = new CreateVMJobs($product->id);
}
}
@@ -417,16 +181,10 @@ class OrderController extends Controller
DB::commit();
- return response()->json([
- 'error' => false,
- 'message' => 'Order updated successfully'
- ]);
+ return response()->json(['error' => false, 'message' => 'Order updated successfully']);
} catch (\Exception $e) {
DB::rollBack();
- return response()->json([
- 'error' => true,
- 'message' => $e->getMessage()
- ], 500);
+ return response()->json(['error' => true, 'message' => $e->getMessage()], 500);
}
}
@@ -437,7 +195,7 @@ class OrderController extends Controller
if (!$order) {
return response()->json([
'error' => true,
- 'message' => "Can't find order by ID #" . $id
+ 'message' => "Can't find order by ID #" . $id,
], 404);
}
@@ -458,16 +216,10 @@ class OrderController extends Controller
DB::commit();
- return response()->json([
- 'error' => false,
- 'message' => 'Order deleted successfully'
- ]);
+ return response()->json(['error' => false, 'message' => 'Order deleted successfully']);
} catch (\Exception $e) {
DB::rollBack();
- return response()->json([
- 'error' => true,
- 'message' => $e->getMessage()
- ], 500);
+ return response()->json(['error' => true, 'message' => $e->getMessage()], 500);
}
}
@@ -478,7 +230,7 @@ class OrderController extends Controller
if (!$order) {
return response()->json([
'error' => true,
- 'message' => "Can't find order by ID #" . $id
+ 'message' => "Can't find order by ID #" . $id,
], 404);
}
@@ -487,16 +239,10 @@ class OrderController extends Controller
$this->orderRepository->update($id, ['status' => 1]);
DB::commit();
- return response()->json([
- 'error' => false,
- 'message' => 'Order accepted successfully'
- ]);
+ return response()->json(['error' => false, 'message' => 'Order accepted successfully']);
} catch (\Exception $e) {
DB::rollBack();
- return response()->json([
- 'error' => true,
- 'message' => $e->getMessage()
- ], 500);
+ return response()->json(['error' => true, 'message' => $e->getMessage()], 500);
}
}
@@ -507,7 +253,7 @@ class OrderController extends Controller
if (!$order) {
return response()->json([
'error' => true,
- 'message' => "Can't find order by ID #" . $id
+ 'message' => "Can't find order by ID #" . $id,
], 404);
}
@@ -516,16 +262,10 @@ class OrderController extends Controller
$this->orderRepository->update($id, ['status' => 2]);
DB::commit();
- return response()->json([
- 'error' => false,
- 'message' => 'Order cancelled successfully'
- ]);
+ return response()->json(['error' => false, 'message' => 'Order cancelled successfully']);
} catch (\Exception $e) {
DB::rollBack();
- return response()->json([
- 'error' => true,
- 'message' => $e->getMessage()
- ], 500);
+ return response()->json(['error' => true, 'message' => $e->getMessage()], 500);
}
}
@@ -541,21 +281,4 @@ class OrderController extends Controller
'path' => $path,
]);
}
-
- private function calculateBillingDetails($billing): array
- {
- $months = match ($billing) {
- 2 => 1,
- 3 => 3,
- 4 => 6,
- 5 => 12,
- default => 0
- };
-
- return [
- 'next_due_date' => $months ? Carbon::today()->addMonths($months)->timestamp : null,
- 'next_invoice' => $months ? Carbon::today()->addMonths($months)->timestamp : null,
- 'billing' => $months ?: 1
- ];
- }
}
diff --git a/app/Http/Controllers/Api/Service/OrderController.php b/app/Http/Controllers/Api/Service/OrderController.php
index 9f2cda9..c715037 100644
--- a/app/Http/Controllers/Api/Service/OrderController.php
+++ b/app/Http/Controllers/Api/Service/OrderController.php
@@ -2,710 +2,171 @@
namespace App\Http\Controllers\Api\Service;
+use App\Enums\Invoice\InvoiceStatus;
+use App\Enums\Order\OrderStatus;
use App\Http\Controllers\Controller;
use App\Http\Requests\Api\Order\PayOrderRequest;
use App\Http\Requests\Api\Order\StoreOrderRequest;
+use App\Models\Invoice;
+use App\Models\ModuleVmware;
+use App\Models\Order;
+use App\Models\Product;
+use App\Models\Service;
use App\Repositories\Api\Order\OrderRepository;
use App\Services\Api\Order\OrderService;
-use App\Enums\Order\OrderStatus;
-use App\Enums\Invoice\InvoiceStatus;
-use App\Models\Invoice as InvoiceModel;
-use App\Models\Order as OrderModel;
use Carbon\Carbon;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
/**
- * @OA\Tag(
- * name="Orders",
- * description="API Endpoints for Order Management"
- * )
+ * @OA\Tag(name="Orders", description="Order & Invoice management")
*/
class OrderController extends Controller
{
public function __construct(
- protected OrderService $orderService,
- protected OrderRepository $orderRepo
+ protected OrderService $orderService,
+ protected OrderRepository $orderRepo,
) {}
+ // =========================================================================
+ // Orders
+ // =========================================================================
+
/**
- * @OA\Get(
- * path="/orders",
- * operationId="getOrdersList",
- * tags={"Orders"},
- * summary="Get list of orders",
- * description="Returns a list of orders for the authenticated customer",
- * security={{"bearerAuth":{}}},
- * @OA\Parameter(
- * name="total_price",
- * in="query",
- * description="Filter by total price",
- * required=false,
- * @OA\Schema(type="number")
- * ),
- * @OA\Parameter(
- * name="pending",
- * in="query",
- * description="Filter by pending status",
- * required=false,
- * @OA\Schema(type="boolean")
- * ),
- * @OA\Response(
- * response=200,
- * description="Successful operation",
- * @OA\JsonContent(
- * @OA\Property(property="success", type="boolean", example=true),
- * @OA\Property(property="data", type="array", @OA\Items(type="object"))
- * )
- * ),
- * @OA\Response(
- * response=401,
- * description="Unauthenticated"
- * )
- * )
+ * GET /v1/orders — paginated order list (admin / internal)
*/
public function getList(Request $request): JsonResponse
{
try {
- $customer = $request->user();
-
- $filters = [
- 'total_price' => $request->query('total_price'),
- 'pending' => $request->query('pending')
- ];
-
- $orders = $this->orderRepo->getOrders($customer->id, $filters);
-
- return response()->json([
- 'success' => true,
- 'data' => $orders
- ]);
+ $orders = $this->orderRepo->getOrders(
+ $request->user()->id,
+ $request->only(['total_price', 'pending'])
+ );
+ return response()->json(['success' => true, 'data' => $orders]);
} catch (\Exception $e) {
- return response()->json([
- 'success' => false,
- 'message' => $e->getMessage()
- ], 400);
- }
- }
-
- public function getClientOrders(Request $request): JsonResponse
- {
- try {
- $customer = $request->user();
- $orders = OrderModel::query()
- ->with(['invoice'])
- ->where('customer_id', $customer->id)
- ->orderByDesc('created_at')
- ->get();
-
- $data = $orders->map(function ($order) {
- return [
- 'id' => $order->id,
- 'order_num' => $order->order_num,
- 'status' => $order->status,
- 'status_text' => OrderStatus::from((int) $order->status)->label(),
- 'amount' => (float) $order->amount,
- 'first_payment' => $order->first_payment,
- 'next_payment' => $order->next_payment,
- 'created_at' => $order->created_at?->toISOString(),
- 'invoice' => [
- 'id' => $order->invoice?->id,
- 'invoice_num' => $order->invoice?->invoice_num,
- 'status' => $order->invoice?->status,
- 'total' => $order->invoice?->total,
- ],
- ];
- });
-
- return response()->json([
- 'success' => true,
- 'data' => $data,
- ]);
- } catch (\Exception $e) {
- return response()->json([
- 'success' => false,
- 'message' => $e->getMessage(),
- ], 400);
- }
- }
-
- public function getClientOrderDetail(Request $request, int $id): JsonResponse
- {
- try {
- $customer = $request->user();
- $order = OrderModel::query()
- ->with(['invoice', 'orderItems.product', 'orderItems.addons.addonItems', 'customer'])
- ->where('customer_id', $customer->id)
- ->where('id', $id)
- ->first();
-
- if (!$order) {
- return response()->json([
- 'success' => false,
- 'message' => 'Đơn hàng không tồn tại',
- ], 404);
- }
-
- return response()->json([
- 'success' => true,
- 'data' => [
- 'id' => $order->id,
- 'order_num' => $order->order_num,
- 'status' => $order->status,
- 'status_text' => OrderStatus::from((int) $order->status)->label(),
- 'amount' => (float) $order->amount,
- 'created_at' => $this->toIsoDate($order->created_at),
- 'customer' => [
- 'id' => $order->customer?->id,
- 'name' => $order->customer?->fullname,
- 'email' => $order->customer?->email,
- 'address' => $order->customer?->address,
- ],
- 'invoice' => [
- 'id' => $order->invoice?->id,
- 'invoice_num' => $order->invoice?->invoice_num,
- 'status' => $order->invoice?->status,
- 'status_text' => $order->invoice?->status ? InvoiceStatus::from((int) $order->invoice->status)->label() : null,
- 'sub_total' => $order->invoice?->sub_total,
- 'tax' => $order->invoice?->tax,
- 'total' => $order->invoice?->total,
- 'due_date' => $this->toIsoDate($order->invoice?->due_date),
- 'paid_at' => $this->toIsoDate($order->invoice?->date_paid),
- ],
- 'items' => $order->orderItems->map(function ($item) {
- return [
- 'id' => $item->id,
- 'product_name' => $item->product?->name,
- 'billing_cycle' => $item->billing_cycle,
- 'server_name' => $item->server_name,
- 'price' => (float) $item->price,
- 'addons' => $item->addons->map(function ($addon) {
- return [
- 'id' => $addon->id,
- 'name' => $addon->addonItems?->name,
- 'qty' => $addon->qty,
- 'price' => (float) $addon->price,
- ];
- }),
- ];
- }),
- ],
- ]);
- } catch (\Exception $e) {
- return response()->json([
- 'success' => false,
- 'message' => $e->getMessage(),
- ], 400);
- }
- }
-
- public function getClientInvoices(Request $request): JsonResponse
- {
- try {
- $customer = $request->user();
- $orders = OrderModel::query()
- ->with(['invoice', 'orderItems.product'])
- ->where('customer_id', $customer->id)
- ->whereNotNull('invoice_id')
- ->orderByDesc('created_at')
- ->get();
-
- $data = $orders->map(function ($order) {
- $firstItem = $order->orderItems->first();
- $invoice = $order->invoice;
-
- return [
- 'id' => $invoice?->id,
- 'invoice_num' => $invoice?->invoice_num,
- 'service_name' => $firstItem?->product?->name ?? ('Đơn hàng ' . $order->order_num),
- 'amount' => (float) ($invoice?->total ?? $order->amount ?? 0),
- 'status' => $invoice?->status,
- 'status_text' => $invoice?->status ? InvoiceStatus::from((int) $invoice->status)->label() : null,
- 'created_at' => $this->toIsoDate($invoice?->created_at ?? $order->created_at),
- 'order_id' => $order->id,
- 'order_num' => $order->order_num,
- ];
- })->filter(fn($row) => !empty($row['id']))->values();
-
- return response()->json([
- 'success' => true,
- 'data' => $data,
- ]);
- } catch (\Exception $e) {
- return response()->json([
- 'success' => false,
- 'message' => $e->getMessage(),
- ], 400);
- }
- }
-
- public function getClientInvoiceDetail(Request $request, int $id): JsonResponse
- {
- try {
- $customer = $request->user();
- $order = OrderModel::query()
- ->with(['invoice', 'orderItems.product', 'customer', 'vmWare.ipAddress', 'vmWare.os'])
- ->where('customer_id', $customer->id)
- ->where('invoice_id', $id)
- ->first();
-
- if (!$order || !$order->invoice) {
- return response()->json([
- 'success' => false,
- 'message' => 'Hóa đơn không tồn tại',
- ], 404);
- }
-
- $invoice = $order->invoice;
- $firstItem = $order->orderItems->first();
- $vmware = $order->vmWare->first();
-
- return response()->json([
- 'success' => true,
- 'data' => [
- 'id' => $invoice->id,
- 'invoice_num' => $invoice->invoice_num,
- 'status' => $invoice->status,
- 'status_text' => InvoiceStatus::from((int) $invoice->status)->label(),
- 'sub_total' => (float) ($invoice->sub_total ?? 0),
- 'tax' => (float) ($invoice->tax ?? 0),
- 'total' => (float) ($invoice->total ?? 0),
- 'created_at' => $this->toIsoDate($invoice->created_at),
- 'due_date' => $this->toIsoDate($invoice->due_date),
- 'paid_at' => $this->toIsoDate($invoice->date_paid),
- 'payment_method' => $order->payment_method ?? null,
- 'order' => [
- 'id' => $order->id,
- 'order_num' => $order->order_num,
- 'amount' => (float) $order->amount,
- ],
- 'service' => [
- 'product_id' => $firstItem?->product?->id,
- 'product_uuid' => $firstItem?->product?->uuid,
- 'name' => $firstItem?->product?->name,
- 'billing_cycle' => $firstItem?->billing_cycle,
- 'server_name' => $firstItem?->server_name,
- ],
- 'customer' => [
- 'id' => $order->customer?->id,
- 'name' => $order->customer?->fullname,
- 'email' => $order->customer?->email,
- 'address' => $order->customer?->address,
- ],
- 'items' => $order->orderItems->map(function ($item) {
- return [
- 'id' => $item->id,
- 'product_name' => $item->product?->name,
- 'billing_cycle' => $item->billing_cycle,
- 'server_name' => $item->server_name,
- 'price' => (float) $item->price,
- ];
- }),
- 'vmware' => $vmware ? [
- 'id' => $vmware->id,
- 'name' => $vmware->name,
- 'hostname' => $vmware->hostname,
- 'ip_address' => $vmware->ipAddress?->ip,
- 'os_name' => $vmware->os?->name,
- 'status' => $vmware->status,
- 'next_due_date' => $this->toIsoDate($vmware->next_due_date),
- ] : null,
- ],
- ]);
- } catch (\Exception $e) {
- return response()->json([
- 'success' => false,
- 'message' => $e->getMessage(),
- ], 400);
- }
- }
-
- public function getClientServices(Request $request): JsonResponse
- {
- try {
- $customer = $request->user();
-
- $invoices = \App\Models\Invoice::query()
- ->with(['order.orderItems.product', 'order.vmWare.ipAddress', 'order.vmWare.os'])
- ->where('customer_id', $customer->id)
- ->where('status', \App\Enums\Invoice\InvoiceStatus::PAID->value)
- ->orderByDesc('created_at')
- ->get();
-
- $data = $invoices->map(function ($invoice) {
- $order = $invoice->order;
- $firstItem = $order?->orderItems->first();
- $vmware = $order?->vmWare->first();
-
- return [
- 'id' => $invoice->id,
- 'invoice_num' => $invoice->order_num,
- 'service_name' => $firstItem?->product?->name ?? ('Đơn hàng ' . $order?->order_num),
- 'product_type' => $firstItem?->product?->product_type,
- 'server_name' => $firstItem?->server_name,
- 'billing_cycle'=> $firstItem?->billing_cycle,
- 'amount' => (float) ($invoice->total ?? 0),
- 'auto_renew' => (bool) ($invoice->auto_renew ?? false),
- 'vm_id' => $invoice->vm_id,
- 'ip_address' => $vmware?->ipAddress?->ip,
- 'os_name' => $vmware?->os?->name,
- 'vm_status' => $vmware?->status,
- 'paid_at' => $this->toIsoDate($invoice->date_paid),
- 'next_due_date'=> $vmware ? $this->toIsoDate($vmware->next_due_date) : null,
- 'created_at' => $this->toIsoDate($invoice->created_at),
- 'order_id' => $order?->id,
- 'order_num' => $order?->order_num,
- ];
- })->values();
-
- return response()->json([
- 'success' => true,
- 'data' => $data,
- ]);
- } catch (\Exception $e) {
- return response()->json([
- 'success' => false,
- 'message' => $e->getMessage(),
- ], 400);
- }
- }
-
- public function getClientInvoiceDetailByOrderNum(Request $request, string $orderNum): JsonResponse
- {
- try {
- $customer = $request->user();
- $order = OrderModel::query()
- ->with(['invoice', 'orderItems.product', 'customer', 'vmWare.ipAddress', 'vmWare.os'])
- ->where('customer_id', $customer->id)
- ->where('order_num', $orderNum)
- ->first();
-
- if (!$order || !$order->invoice) {
- return response()->json([
- 'success' => false,
- 'message' => 'Hóa đơn không tồn tại',
- ], 404);
- }
-
- $invoice = $order->invoice;
- $firstItem = $order->orderItems->first();
- $vmware = $order->vmWare->first();
-
- return response()->json([
- 'success' => true,
- 'data' => [
- 'id' => $invoice->id,
- 'invoice_num' => $invoice->invoice_num,
- 'status' => $invoice->status,
- 'status_text' => InvoiceStatus::from((int) $invoice->status)->label(),
- 'sub_total' => (float) ($invoice->sub_total ?? 0),
- 'tax' => (float) ($invoice->tax ?? 0),
- 'total' => (float) ($invoice->total ?? 0),
- 'created_at' => $this->toIsoDate($invoice->created_at),
- 'due_date' => $this->toIsoDate($invoice->due_date),
- 'paid_at' => $this->toIsoDate($invoice->date_paid),
- 'payment_method' => $order->payment_method ?? null,
- 'order' => [
- 'id' => $order->id,
- 'order_num' => $order->order_num,
- 'amount' => (float) $order->amount,
- ],
- 'service' => [
- 'product_id' => $firstItem?->product?->id,
- 'product_uuid' => $firstItem?->product?->uuid,
- 'name' => $firstItem?->product?->name,
- 'billing_cycle' => $firstItem?->billing_cycle,
- 'server_name' => $firstItem?->server_name,
- 'product_type' => $firstItem?->product?->product_type,
- 'specs' => [
- 'cpu' => $firstItem?->product?->cpu,
- 'ram' => $firstItem?->product?->ram,
- 'disk' => $firstItem?->product?->disk,
- ],
- ],
- 'customer' => [
- 'id' => $order->customer?->id,
- 'name' => $order->customer?->fullname,
- 'email' => $order->customer?->email,
- 'address' => $order->customer?->address,
- ],
- 'items' => $order->orderItems->map(function ($item) {
- return [
- 'id' => $item->id,
- 'product_name' => $item->product?->name,
- 'billing_cycle' => $item->billing_cycle,
- 'server_name' => $item->server_name,
- 'price' => (float) $item->price,
- ];
- }),
- 'vmware' => $vmware ? [
- 'id' => $vmware->id,
- 'name' => $vmware->name,
- 'hostname' => $vmware->hostname,
- 'ip_address' => $vmware->ipAddress?->ip,
- 'os_name' => $vmware->os?->name,
- 'status' => $vmware->status,
- 'username' => $vmware->user,
- 'port' => $vmware->port,
- 'next_due_date' => $this->toIsoDate($vmware->next_due_date),
- ] : null,
- ],
- ]);
- } catch (\Exception $e) {
- return response()->json([
- 'success' => false,
- 'message' => $e->getMessage(),
- ], 400);
- }
- }
-
- public function updateClientServiceAutoRenew(Request $request, int $id): JsonResponse
- {
- try {
- $customer = $request->user();
- $validated = $request->validate([
- 'auto_renew' => ['required', 'boolean'],
- ]);
-
- $invoice = InvoiceModel::query()
- ->where('id', $id)
- ->where('customer_id', $customer->id)
- ->first();
-
- if (!$invoice) {
- return response()->json([
- 'success' => false,
- 'message' => 'Không tìm thấy dịch vụ tương ứng',
- ], 404);
- }
-
- $invoice->auto_renew = (bool) $validated['auto_renew'];
- $invoice->save();
-
- return response()->json([
- 'success' => true,
- 'message' => 'Cập nhật tự động gia hạn thành công',
- 'data' => [
- 'id' => $invoice->id,
- 'invoice_num' => $invoice->order_num,
- 'auto_renew' => (bool) $invoice->auto_renew,
- ],
- ]);
- } catch (\Exception $e) {
- return response()->json([
- 'success' => false,
- 'message' => $e->getMessage(),
- ], 400);
- }
- }
-
- private function toIsoDate($value): ?string
- {
- if (empty($value)) {
- return null;
- }
-
- try {
- if ($value instanceof \DateTimeInterface) {
- return Carbon::instance($value)->toISOString();
- }
-
- if (is_numeric($value)) {
- return Carbon::createFromTimestamp((int) $value)->toISOString();
- }
-
- return Carbon::parse($value)->toISOString();
- } catch (\Throwable) {
- return null;
+ return $this->error($e->getMessage());
}
}
/**
- * @OA\Post(
- * path="/orders",
- * operationId="storeOrder",
- * tags={"Orders"},
- * summary="Create a new order",
- * description="Create a new order for a product",
- * security={{"bearerAuth":{}}},
- * @OA\RequestBody(
- * required=true,
- * @OA\JsonContent(
- * required={"product_id","billing_cycle"},
- * @OA\Property(property="product_id", type="string", example="550e8400-e29b-41d4-a716-446655440000", description="Product UUID"),
- * @OA\Property(property="billing_cycle", type="string", example="monthly", enum={"monthly", "quarterly", "semi_annually", "annually", "biennially"}),
- * @OA\Property(property="server_name", type="string", example="My VPS Server"),
- * @OA\Property(property="os_id", type="integer", example=1, description="Operating System ID"),
- * @OA\Property(property="addons", type="array", @OA\Items(
- * @OA\Property(property="addon_id", type="integer", example=1),
- * @OA\Property(property="qty", type="integer", example=1)
- * ))
- * )
- * ),
- * @OA\Response(
- * response=201,
- * description="Order created successfully",
- * @OA\JsonContent(
- * @OA\Property(property="success", type="boolean", example=true),
- * @OA\Property(property="data", type="object"),
- * @OA\Property(property="message", type="string", example="Tạo đơn hàng thành công")
- * )
- * ),
- * @OA\Response(
- * response=401,
- * description="Unauthenticated"
- * ),
- * @OA\Response(
- * response=422,
- * description="Validation error"
- * )
+ * GET /v1/client/orders — customer's order list
+ */
+ public function getClientOrders(Request $request): JsonResponse
+ {
+ try {
+ $orders = Order::query()
+ ->with(['invoice'])
+ ->where('customer_id', $request->user()->id)
+ ->orderByDesc('created_at')
+ ->get();
+
+ $data = $orders->map(fn(Order $order) => [
+ 'id' => $order->id,
+ 'order_num' => $order->order_num,
+ 'status' => $order->status,
+ 'status_text' => OrderStatus::from((int) $order->status)->label(),
+ 'total' => (float) $order->total,
+ 'created_at' => $this->isoDate($order->created_at),
+ 'invoice' => $order->invoice ? [
+ 'id' => $order->invoice->id,
+ 'invoice_number' => $order->invoice->invoice_number,
+ 'status' => $order->invoice->status,
+ 'total' => $order->invoice->total,
+ ] : null,
+ ]);
+
+ return response()->json(['success' => true, 'data' => $data]);
+ } catch (\Exception $e) {
+ return $this->error($e->getMessage());
+ }
+ }
+
+ /**
+ * GET /v1/client/orders/{id} — order detail
+ */
+ public function getClientOrderDetail(Request $request, int $id): JsonResponse
+ {
+ try {
+ $order = Order::query()
+ ->with(['invoice', 'orderItems.product', 'customer'])
+ ->where('customer_id', $request->user()->id)
+ ->where('id', $id)
+ ->first();
+
+ if (!$order) {
+ return $this->notFound('Đơn hàng không tồn tại.');
+ }
+
+ return response()->json(['success' => true, 'data' => $this->formatOrderDetail($order)]);
+ } catch (\Exception $e) {
+ return $this->error($e->getMessage());
+ }
+ }
+
+ /**
+ * POST /v1/orders — create a new order
+ *
+ * @OA\Post(path="/orders", operationId="storeOrder", tags={"Orders"},
+ * security={{"bearerAuth":{}}},
+ * @OA\RequestBody(required=true, @OA\JsonContent(
+ * required={"product_id","billing_cycle"},
+ * @OA\Property(property="product_id", type="string", example="uuid-here"),
+ * @OA\Property(property="billing_cycle", type="string", example="annually",
+ * enum={"monthly","quarterly","semi_annually","annually","biennially"}),
+ * @OA\Property(property="server_name", type="string", example="my-vps-01"),
+ * @OA\Property(property="os_id", type="integer", example=1),
+ * @OA\Property(property="addons", type="array", @OA\Items(
+ * @OA\Property(property="addon_id", type="integer", example=1),
+ * @OA\Property(property="qty", type="integer", example=2)
+ * ))
+ * )),
+ * @OA\Response(response=201, description="Order created successfully")
* )
*/
public function storeOrder(StoreOrderRequest $request): JsonResponse
{
try {
$customer = $request->user();
+ $data = $request->validated();
- $data = $request->validated();
-
- // Convert UUID to ID for internal processing
- $product = \App\Models\Product::where('uuid', $data['product_id'])->first();
-
+ $product = Product::where('uuid', $data['product_id'])->first();
if (!$product) {
throw new \Exception('Sản phẩm không tìm thấy hoặc đã bị xóa.');
}
-
$data['product_id'] = $product->id;
- $customer = $request->user();
-
- if (!$customer) {
- return response()->json([
- 'success' => false,
- 'message' => 'Người dùng chưa đăng nhập hoặc phiên đăng nhập hết hạn.'
- ], 401);
- }
-
$result = $this->orderService->create($data, $customer);
- // Load relationships for response
- $order = $result['order']->load(['orderItems.addons.addonItems', 'orderItems.product', 'invoice', 'customer']);
-
- // Format response
- $responseData = [
- 'order' => [
- 'id' => $order->id,
- 'order_num' => $order->order_num,
- 'status' => $order->status,
- 'status_text' => \App\Enums\Order\OrderStatus::from($order->status)->label(),
- 'amount' => $order->amount,
- 'first_payment' => $order->first_payment,
- 'next_payment' => $order->next_payment,
- 'created_at' => $order->created_at->toISOString(),
- ],
- 'invoice' => [
- 'id' => $order->invoice?->id,
- 'invoice_num' => $order->invoice?->invoice_num,
- 'sub_total' => $order->invoice?->sub_total,
- 'tax' => $order->invoice?->tax,
- 'total' => $order->invoice?->total,
- 'status' => $order->invoice?->status,
- 'due_date' => $order->invoice?->due_date,
- ],
- 'items' => $order->orderItems->map(function ($item) {
- return [
- 'id' => $item->id,
- 'product' => [
- 'id' => $item->product?->id,
- 'uuid' => $item->product?->uuid,
- 'name' => $item->product?->name,
- 'product_type' => $item->product?->product_type,
- 'specs' => [
- 'cpu' => $item->product?->cpu,
- 'ram' => $item->product?->ram,
- 'disk' => $item->product?->disk,
- 'bandwidth' => $item->product?->bandwidth,
- ],
- ],
- 'billing_cycle' => $item->billing_cycle,
- 'server_name' => $item->server_name,
- 'os_id' => $item->os_id,
- 'price' => $item->price,
- 'addons' => $item->addons->map(function ($addon) {
- return [
- 'id' => $addon->id,
- 'addon_id' => $addon->addon_id,
- 'addon_name' => $addon->addonItems?->name,
- 'qty' => $addon->qty,
- 'price' => $addon->price,
- ];
- }),
- ];
- }),
- 'customer' => [
- 'id' => $order->customer?->id,
- 'email' => $order->customer?->email,
- 'name' => trim($order->customer?->firstname . ' ' . $order->customer?->lastname),
- ],
- ];
+ $order = $result['order']->load(['orderItems.product', 'invoice', 'customer']);
+ $invoice = $result['invoice'];
return response()->json([
'success' => true,
- 'data' => $responseData,
- 'message' => 'Tạo đơn hàng thành công',
- 'notice' => 'Dịch vụ VPS sẽ được khởi tạo trong vòng 5-10 phút sau khi thanh toán. Bạn sẽ nhận được email thông báo khi hoàn tất.'
+ 'message' => 'Tạo đơn hàng thành công.',
+ 'notice' => 'Dịch vụ VPS sẽ được khởi tạo trong 5-10 phút sau khi thanh toán.',
+ 'data' => [
+ 'order' => $this->formatOrderSummary($order),
+ 'invoice' => $this->formatInvoiceSummary($invoice),
+ 'items' => $this->formatOrderItems($order->orderItems),
+ ],
], 201);
-
} catch (\Exception $e) {
- return response()->json([
- 'success' => false,
- 'message' => $e->getMessage()
- ], 400);
+ return $this->error($e->getMessage());
}
}
/**
- * @OA\Post(
- * path="/orders/{id}/pay",
- * operationId="payOrder",
- * tags={"Orders"},
- * summary="Pay for an order",
- * description="Process payment for a specific order",
- * security={{"bearerAuth":{}}},
- * @OA\Parameter(
- * name="id",
- * in="path",
- * description="Order ID",
- * required=true,
- * @OA\Schema(type="integer")
- * ),
- * @OA\RequestBody(
- * required=true,
- * @OA\JsonContent(
- * required={"payment_method"},
- * @OA\Property(property="payment_method", type="string", example="credit", enum={"credit", "vnpay", "momo", "bank_transfer"})
- * )
- * ),
- * @OA\Response(
- * response=200,
- * description="Payment successful",
- * @OA\JsonContent(
- * @OA\Property(property="success", type="boolean", example=true),
- * @OA\Property(property="message", type="string", example="Thanh toán thành công")
- * )
- * ),
- * @OA\Response(
- * response=401,
- * description="Unauthenticated"
- * ),
- * @OA\Response(
- * response=403,
- * description="Forbidden - Not your order"
- * ),
- * @OA\Response(
- * response=404,
- * description="Order not found"
- * )
+ * POST /v1/orders/{id}/pay — pay for a pending order
+ *
+ * @OA\Post(path="/orders/{id}/pay", operationId="payOrder", tags={"Orders"},
+ * security={{"bearerAuth":{}}},
+ * @OA\Parameter(name="id", in="path", required=true, @OA\Schema(type="integer")),
+ * @OA\RequestBody(required=true, @OA\JsonContent(
+ * required={"payment_method"},
+ * @OA\Property(property="payment_method", type="string",
+ * enum={"credit","bank_transfer","vnpay","momo"})
+ * )),
+ * @OA\Response(response=200, description="Payment successful")
* )
*/
public function payOrder(int $id, PayOrderRequest $request): JsonResponse
@@ -714,25 +175,389 @@ class OrderController extends Controller
$order = $this->orderRepo->find($id);
if (!$order) {
- return response()->json(['message' => 'Đơn hàng không tồn tại'], 404);
+ return $this->notFound('Đơn hàng không tồn tại.');
}
-
if ($order->customer_id !== $request->user()->id) {
- return response()->json(['message' => 'Không có quyền truy cập'], 403);
+ return response()->json(['success' => false, 'message' => 'Không có quyền truy cập.'], 403);
}
$this->orderService->pay($order, $request->payment_method);
- return response()->json([
- 'success' => true,
- 'message' => 'Thanh toán thành công'
- ]);
-
+ return response()->json(['success' => true, 'message' => 'Thanh toán thành công.']);
} catch (\Exception $e) {
- return response()->json([
- 'success' => false,
- 'message' => $e->getMessage()
- ], 400);
+ return $this->error($e->getMessage());
}
}
+
+ // =========================================================================
+ // Invoices
+ // =========================================================================
+
+ /**
+ * GET /v1/client/invoices — customer's invoice list
+ */
+ public function getClientInvoices(Request $request): JsonResponse
+ {
+ try {
+ $invoices = Invoice::query()
+ ->with(['order.orderItems.product'])
+ ->where('customer_id', $request->user()->id)
+ ->orderByDesc('created_at')
+ ->get();
+
+ $data = $invoices->map(fn(Invoice $invoice) => [
+ 'id' => $invoice->id,
+ 'invoice_number' => $invoice->invoice_number,
+ 'service_name' => $invoice->order?->orderItems->first()?->product?->name
+ ?? ('Đơn hàng ' . $invoice->order?->order_num),
+ 'total' => (float) $invoice->total,
+ 'status' => $invoice->status,
+ 'status_text' => InvoiceStatus::tryFrom((int) $invoice->status)?->label() ?? '-',
+ 'created_at' => $this->isoDate($invoice->created_at),
+ 'order_id' => $invoice->order?->id,
+ 'order_num' => $invoice->order?->order_num,
+ ])->values();
+
+ return response()->json(['success' => true, 'data' => $data]);
+ } catch (\Exception $e) {
+ return $this->error($e->getMessage());
+ }
+ }
+
+ /**
+ * GET /v1/client/invoices/{id} — invoice detail
+ */
+ public function getClientInvoiceDetail(Request $request, int $id): JsonResponse
+ {
+ try {
+ $invoice = Invoice::query()
+ ->with([
+ 'order.orderItems.product',
+ 'order.vmWare.ipAddress',
+ 'order.vmWare.os',
+ 'invoiceItems',
+ 'customer',
+ 'attachments',
+ ])
+ ->where('customer_id', $request->user()->id)
+ ->where('id', $id)
+ ->first();
+
+ if (!$invoice) {
+ return $this->notFound('Hóa đơn không tồn tại.');
+ }
+
+ return response()->json(['success' => true, 'data' => $this->formatInvoiceDetail($invoice)]);
+ } catch (\Exception $e) {
+ return $this->error($e->getMessage());
+ }
+ }
+
+ /**
+ * GET /v1/client/invoices/order/{orderNumber} — invoice detail by order number
+ */
+ public function getClientInvoiceDetailByOrderNum(Request $request, string $orderNumber): JsonResponse
+ {
+ try {
+ $order = Order::query()
+ ->with([
+ 'invoice.invoiceItems',
+ 'invoice.attachments',
+ 'invoice.customer',
+ 'orderItems.product',
+ 'customer',
+ 'vmWare.ipAddress',
+ 'vmWare.os',
+ ])
+ ->where('customer_id', $request->user()->id)
+ ->where('order_num', $orderNumber)
+ ->first();
+
+ if (!$order?->invoice) {
+ return $this->notFound('Hóa đơn không tồn tại.');
+ }
+
+ // Reuse formatInvoiceDetail with already-loaded relations
+ $invoice = $order->invoice;
+ // Attach order + vmWare so formatter can use them
+ $invoice->setRelation('order', $order);
+ $invoice->setRelation('customer', $order->customer);
+
+ return response()->json(['success' => true, 'data' => $this->formatInvoiceDetail($invoice)]);
+ } catch (\Exception $e) {
+ return $this->error($e->getMessage());
+ }
+ }
+
+ // =========================================================================
+ // Services (dich-vu)
+ // =========================================================================
+
+ /**
+ * GET /v1/client/dich-vu — list of customer's services
+ */
+ public function getClientServices(Request $request): JsonResponse
+ {
+ try {
+ $services = Service::query()
+ ->with(['product', 'addons', 'invoice', 'orderItem'])
+ ->where('customer_id', $request->user()->id)
+ ->orderByDesc('created_at')
+ ->get();
+
+ $data = $services->map(function (Service $service) {
+ $vmware = $service->vm_id
+ ? ModuleVmware::with(['ipAddress', 'os'])->find((int) $service->vm_id)
+ : null;
+
+ return [
+ 'id' => $service->id,
+ 'service_name' => $service->product?->name ?? $service->server_name,
+ 'server_name' => $service->server_name,
+ 'product_type' => $service->product?->product_type,
+ 'billing_cycle' => $service->orderItem?->billing_cycle,
+ 'status' => $service->status,
+ 'status_text' => $service->status_label,
+ 'auto_renew' => (bool) $service->auto_renew,
+ 'started_at' => $this->isoDate($service->started_at),
+ 'expires_at' => $this->isoDate($service->expires_at),
+ 'invoice_id' => $service->invoice_id,
+ 'invoice_number' => $service->invoice?->invoice_number,
+ 'addons' => $service->addons->map(fn($a) => [
+ 'type' => $a->addon_type,
+ 'value' => $a->addon_value,
+ 'price' => $a->price,
+ ])->all(),
+ 'vmware' => $vmware ? [
+ 'id' => $vmware->id,
+ 'name' => $vmware->name,
+ 'hostname' => $vmware->hostname,
+ 'ip_address' => $vmware->ipAddress?->ip,
+ 'os_name' => $vmware->os?->name,
+ 'status' => $vmware->status,
+ ] : null,
+ ];
+ })->values();
+
+ return response()->json(['success' => true, 'data' => $data]);
+ } catch (\Exception $e) {
+ return $this->error($e->getMessage());
+ }
+ }
+
+ /**
+ * PATCH /v1/client/dich-vu/{id}/auto-renew — toggle auto-renewal on a service
+ */
+ public function updateClientServiceAutoRenew(Request $request, int $id): JsonResponse
+ {
+ try {
+ $validated = $request->validate(['auto_renew' => ['required', 'boolean']]);
+
+ $service = Service::query()
+ ->where('id', $id)
+ ->where('customer_id', $request->user()->id)
+ ->first();
+
+ if (!$service) {
+ return $this->notFound('Không tìm thấy dịch vụ.');
+ }
+
+ $service->update(['auto_renew' => (bool) $validated['auto_renew']]);
+
+ return response()->json([
+ 'success' => true,
+ 'message' => 'Cập nhật tự động gia hạn thành công.',
+ 'data' => ['id' => $service->id, 'auto_renew' => (bool) $service->auto_renew],
+ ]);
+ } catch (\Exception $e) {
+ return $this->error($e->getMessage());
+ }
+ }
+
+ // =========================================================================
+ // Private — formatters
+ // =========================================================================
+
+ private function formatOrderSummary(Order $order): array
+ {
+ return [
+ 'id' => $order->id,
+ 'order_num' => $order->order_num,
+ 'status' => $order->status,
+ 'status_text' => OrderStatus::from((int) $order->status)->label(),
+ 'sub_total' => $order->sub_total,
+ 'total' => $order->total,
+ 'created_at' => $this->isoDate($order->created_at),
+ ];
+ }
+
+ private function formatInvoiceSummary(Invoice $invoice): array
+ {
+ return [
+ 'id' => $invoice->id,
+ 'invoice_number' => $invoice->invoice_number,
+ 'sub_total' => $invoice->sub_total,
+ 'tax' => $invoice->tax,
+ 'total' => $invoice->total,
+ 'status' => $invoice->status,
+ 'due_date' => $this->isoDate($invoice->due_date),
+ ];
+ }
+
+ private function formatOrderItems($items): array
+ {
+ return $items->map(fn($item) => [
+ 'id' => $item->id,
+ 'billing_cycle' => $item->billing_cycle,
+ 'months' => $item->months,
+ 'server_name' => $item->server_name,
+ 'os_id' => $item->os_id,
+ 'price' => (float) $item->price,
+ 'product' => $item->product ? [
+ 'id' => $item->product->id,
+ 'uuid' => $item->product->uuid,
+ 'name' => $item->product->name,
+ 'product_type' => $item->product->product_type,
+ 'specs' => [
+ 'cpu' => $item->product->cpu,
+ 'ram' => $item->product->ram,
+ 'disk' => $item->product->disk,
+ 'bandwidth' => $item->product->bandwidth,
+ ],
+ ] : null,
+ ])->all();
+ }
+
+ private function formatOrderDetail(Order $order): array
+ {
+ $invoice = $order->invoice;
+
+ return [
+ 'id' => $order->id,
+ 'order_num' => $order->order_num,
+ 'status' => $order->status,
+ 'status_text' => OrderStatus::from((int) $order->status)->label(),
+ 'total' => (float) $order->total,
+ 'created_at' => $this->isoDate($order->created_at),
+ 'customer' => [
+ 'id' => $order->customer?->id,
+ 'name' => $order->customer?->fullname,
+ 'email' => $order->customer?->email,
+ ],
+ 'invoice' => $invoice ? [
+ 'id' => $invoice->id,
+ 'invoice_number' => $invoice->invoice_number,
+ 'status' => $invoice->status,
+ 'status_text' => InvoiceStatus::tryFrom((int) $invoice->status)?->label() ?? '-',
+ 'sub_total' => $invoice->sub_total,
+ 'tax' => $invoice->tax,
+ 'total' => $invoice->total,
+ 'due_date' => $this->isoDate($invoice->due_date),
+ 'paid_at' => $this->isoDate($invoice->paid_at),
+ ] : null,
+ 'items' => $this->formatOrderItems($order->orderItems),
+ ];
+ }
+
+ private function formatInvoiceDetail(Invoice $invoice): array
+ {
+ $order = $invoice->order;
+ $vmware = $order?->vmWare->first();
+
+ return [
+ 'id' => $invoice->id,
+ 'invoice_number' => $invoice->invoice_number,
+ 'invoice_type' => $invoice->invoice_type,
+ 'status' => $invoice->status,
+ 'status_text' => InvoiceStatus::tryFrom((int) $invoice->status)?->label() ?? '-',
+ 'sub_total' => (float) $invoice->sub_total,
+ 'discount_amount' => (float) ($invoice->discount_amount ?? 0),
+ 'tax' => (float) $invoice->tax,
+ 'total' => (float) $invoice->total,
+ 'payment_method' => $invoice->payment_method,
+ 'auto_renew' => (bool) $invoice->auto_renew,
+ 'created_at' => $this->isoDate($invoice->created_at),
+ 'due_date' => $this->isoDate($invoice->due_date),
+ 'paid_at' => $this->isoDate($invoice->paid_at),
+ 'customer' => $invoice->customer ? [
+ 'id' => $invoice->customer->id,
+ 'name' => $invoice->customer->fullname,
+ 'email' => $invoice->customer->email,
+ 'address' => $invoice->customer->address,
+ ] : null,
+ 'order' => $order ? [
+ 'id' => $order->id,
+ 'order_num' => $order->order_num,
+ 'total' => (float) $order->total,
+ ] : null,
+ 'service' => ($firstItem = $order?->orderItems->first()) ? [
+ 'product_id' => $firstItem->product?->id,
+ 'product_uuid' => $firstItem->product?->uuid,
+ 'name' => $firstItem->product?->name,
+ 'billing_cycle' => $firstItem->billing_cycle,
+ 'server_name' => $firstItem->server_name,
+ 'specs' => [
+ 'cpu' => $firstItem->product?->cpu,
+ 'ram' => $firstItem->product?->ram,
+ 'disk' => $firstItem->product?->disk,
+ ],
+ ] : null,
+ 'items' => ($invoice->relationLoaded('invoiceItems') ? $invoice->invoiceItems : collect())->map(fn($item) => [
+ 'id' => $item->id,
+ 'type' => $item->type,
+ 'description' => $item->description,
+ 'months' => $item->months,
+ 'quantity' => $item->quantity,
+ 'unit_price' => (float) $item->unit_price,
+ 'total_price' => (float) $item->total_price,
+ ])->all(),
+ 'attachments' => ($invoice->relationLoaded('attachments') ? $invoice->attachments : collect())->map(fn($a) => [
+ 'id' => $a->id,
+ 'filename' => $a->filename,
+ 'url' => $a->url,
+ 'mime_type' => $a->mime_type,
+ ])->all(),
+ 'vmware' => $vmware ? [
+ 'id' => $vmware->id,
+ 'name' => $vmware->name,
+ 'hostname' => $vmware->hostname,
+ 'ip_address' => $vmware->ipAddress?->ip,
+ 'os_name' => $vmware->os?->name,
+ 'status' => $vmware->status,
+ 'next_due_date' => $this->isoDate($vmware->next_due_date),
+ ] : null,
+ ];
+ }
+
+ // =========================================================================
+ // Micro-helpers
+ // =========================================================================
+
+ private function isoDate($value): ?string
+ {
+ if (empty($value)) {
+ return null;
+ }
+ try {
+ if ($value instanceof \DateTimeInterface) {
+ return Carbon::instance($value)->toISOString();
+ }
+ if (is_numeric($value)) {
+ return Carbon::createFromTimestamp((int) $value)->toISOString();
+ }
+ return Carbon::parse($value)->toISOString();
+ } catch (\Throwable) {
+ return null;
+ }
+ }
+
+ private function error(string $message, int $code = 400): JsonResponse
+ {
+ return response()->json(['success' => false, 'message' => $message], $code);
+ }
+
+ private function notFound(string $message): JsonResponse
+ {
+ return response()->json(['success' => false, 'message' => $message], 404);
+ }
}
diff --git a/app/Http/Controllers/Api/System/CompressionTestController.php b/app/Http/Controllers/Api/System/CompressionTestController.php
new file mode 100644
index 0000000..9d2b72a
--- /dev/null
+++ b/app/Http/Controllers/Api/System/CompressionTestController.php
@@ -0,0 +1,89 @@
+ 'Compression test response',
+ 'timestamp' => now()->toISOString(),
+ 'client_capabilities' => CompressionService::getClientCapabilities($request),
+ 'large_data' => $this->generateTestData(),
+ 'meta' => [
+ 'total_items' => 1000,
+ 'page' => 1,
+ 'per_page' => 50,
+ 'compression_enabled' => true,
+ ]
+ ];
+
+ $content = json_encode($testData);
+ $compressionInfo = CompressionService::getCompressionInfo($content);
+
+ return response()->json([
+ 'success' => true,
+ 'data' => $testData,
+ 'compression_analysis' => $compressionInfo,
+ 'response_size' => CompressionService::formatBytes(strlen($content))
+ ]);
+ }
+
+ /**
+ * Get compression statistics
+ */
+ public function stats(Request $request): JsonResponse
+ {
+ return response()->json([
+ 'client_capabilities' => CompressionService::getClientCapabilities($request),
+ 'server_capabilities' => [
+ 'gzip' => function_exists('gzencode'),
+ 'deflate' => function_exists('gzdeflate'),
+ 'brotli' => function_exists('brotli_compress'),
+ ],
+ 'middleware_enabled' => true,
+ 'min_compression_size' => '1KB',
+ 'compression_level' => 6,
+ ]);
+ }
+
+ /**
+ * Generate large test data for compression testing
+ */
+ private function generateTestData(): array
+ {
+ $data = [];
+
+ // Generate repeated patterns that compress well
+ for ($i = 1; $i <= 100; $i++) {
+ $data[] = [
+ 'id' => $i,
+ 'name' => "Test Item Number {$i}",
+ 'description' => 'This is a test description that contains repetitive text to demonstrate compression effectiveness. Lorem ipsum dolor sit amet, consectetur adipiscing elit.',
+ 'status' => $i % 2 === 0 ? 'active' : 'inactive',
+ 'created_at' => now()->subDays($i)->toISOString(),
+ 'metadata' => [
+ 'category' => 'test-category-' . ($i % 5),
+ 'tags' => ['test', 'compression', 'api', 'laravel'],
+ 'properties' => [
+ 'property_1' => 'Repeated property value',
+ 'property_2' => 'Another repeated property value',
+ 'property_3' => 'Yet another repeated property value',
+ ]
+ ]
+ ];
+ }
+
+ return $data;
+ }
+}
\ No newline at end of file
diff --git a/app/Http/Controllers/admin/InvoiceController.php b/app/Http/Controllers/admin/InvoiceController.php
index b4b132b..deb72a6 100644
--- a/app/Http/Controllers/admin/InvoiceController.php
+++ b/app/Http/Controllers/admin/InvoiceController.php
@@ -47,7 +47,7 @@ class InvoiceController extends Controller
{
$validated = $request->validate([
'customer_id' => 'required|numeric',
- 'payment_id' => 'required|numeric',
+ 'payment_method' => 'required|numeric',
'sub_total' => 'required|numeric',
'total' => 'required|numeric',
]);
@@ -58,7 +58,7 @@ class InvoiceController extends Controller
'sub_total' => $validated['sub_total'],
'tax' => $validated['sub_total'] * 0.1,
'total' => $validated['total'],
- 'payment_id' => $validated['payment_id'],
+ 'payment_method' => $validated['payment_method'],
'customer_id' => $validated['customer_id'],
'status' => 0,
];
diff --git a/app/Http/Controllers/admin/OrderController.php b/app/Http/Controllers/admin/OrderController.php
index bf9444d..79ec47c 100644
--- a/app/Http/Controllers/admin/OrderController.php
+++ b/app/Http/Controllers/admin/OrderController.php
@@ -171,7 +171,7 @@ class OrderController extends Controller
'invoice_id' => 0,
'discount_id' => $request->discount_id ?? 0,
'amount' => $total + ($total * (10 / 100)),
- 'payment_id' => $request->payment_id,
+ 'payment_method' => $request->payment_method,
'user_id' => auth()->guard('admin')->user()->id,
'status' => $request->status,
'client_ip' => $request->getClientIp()
@@ -184,7 +184,7 @@ class OrderController extends Controller
'sub_total' => $total,
'tax' => $total * (10 / 100),
'total' => $total + ($total * (10 / 100)),
- 'payment_id' => $request->payment_id,
+ 'payment_method' => $request->payment_method,
'customer_id' => $request->create_invoice ? $request->customer_id : 0,
'status' => $request->status
];
diff --git a/app/Http/Helpers/Helper.php b/app/Http/Helpers/Helper.php
index fdf8ced..e49356e 100644
--- a/app/Http/Helpers/Helper.php
+++ b/app/Http/Helpers/Helper.php
@@ -437,11 +437,11 @@ class Helper
return $html;
}
- public static function generateUniqueCode(): int
+ public static function generateUniqueCode(): string
{
do {
- $code = random_int(1000000, 99999999);
- } while (Order::where("order_num", "=", $code)->first());
+ $code = 'ORD-' . strtoupper(\Illuminate\Support\Str::random(8));
+ } while (Order::where('order_num', $code)->exists());
return $code;
}
diff --git a/app/Http/Helpers/function_helper.php b/app/Http/Helpers/function_helper.php
index e168019..504815c 100644
--- a/app/Http/Helpers/function_helper.php
+++ b/app/Http/Helpers/function_helper.php
@@ -72,7 +72,7 @@ if (!function_exists('user')) {
}
}
-if (!function_exists('generate_order_number')) {
+if (!function_exists('generate_order_num')) {
/**
* Generate a unique order number with given prefix
*
@@ -80,7 +80,7 @@ if (!function_exists('generate_order_number')) {
* @param int $length Length of random number (default 6)
* @return string Unique order number
*/
- function generate_order_number(string $prefix = 'TGS', int $length = 6): string
+ function generate_order_num(string $prefix = 'TGS', int $length = 6): string
{
do {
$randomNumber = random_int(10 ** ($length - 1), (10 ** $length) - 1);
diff --git a/app/Http/Kernel.php b/app/Http/Kernel.php
index ce14713..0410285 100644
--- a/app/Http/Kernel.php
+++ b/app/Http/Kernel.php
@@ -42,6 +42,7 @@ class Kernel extends HttpKernel
'api' => [
'throttle:api',
\Illuminate\Routing\Middleware\SubstituteBindings::class,
+ \App\Http\Middleware\CompressionMiddleware::class,
],
];
@@ -63,13 +64,13 @@ class Kernel extends HttpKernel
'throttle' => \Illuminate\Routing\Middleware\ThrottleRequests::class,
'verified' => \Illuminate\Auth\Middleware\EnsureEmailIsVerified::class,
'LoginCheck' => \App\Http\Middleware\LoginCheck::class,
- 'abilities' => \Laravel\Sanctum\Http\Middleware\CheckAbilities::class,
- 'ability' => \Laravel\Sanctum\Http\Middleware\CheckForAnyAbility::class,
'CustomerLoginCheck' => \App\Http\Middleware\CustomerLoginCheck::class,
'IsLoginCheck' => \App\Http\Middleware\IsLoginCheck::class,
'Staff' => \App\Http\Middleware\StaffCheck::class,
'json.response' => \App\Http\Middleware\ForceJsonResponse::class,
'api.auth' => \App\Http\Middleware\ApiAuthCheck::class,
'perm' => \App\Http\Middleware\CheckPermission::class,
+ 'compression' => \App\Http\Middleware\CompressionMiddleware::class,
+ 'compression.advanced' => \App\Http\Middleware\AdvancedCompressionMiddleware::class,
];
}
diff --git a/app/Http/Middleware/AdvancedCompressionMiddleware.php b/app/Http/Middleware/AdvancedCompressionMiddleware.php
new file mode 100644
index 0000000..4ad052f
--- /dev/null
+++ b/app/Http/Middleware/AdvancedCompressionMiddleware.php
@@ -0,0 +1,131 @@
+ 90%
+
+ /**
+ * Content types that should be compressed
+ */
+ private const COMPRESSIBLE_TYPES = [
+ 'application/json',
+ 'text/plain',
+ 'text/html',
+ 'text/xml',
+ 'application/xml',
+ 'text/css',
+ 'application/javascript',
+ 'text/javascript'
+ ];
+
+ /**
+ * Handle an incoming request.
+ */
+ public function handle(Request $request, Closure $next): Response
+ {
+ $response = $next($request);
+
+ if (!$this->shouldCompress($request, $response)) {
+ return $response;
+ }
+
+ return $this->compressResponse($request, $response);
+ }
+
+ /**
+ * Compress the response content
+ */
+ private function compressResponse(Request $request, Response $response): Response
+ {
+ $content = $response->getContent();
+ $originalLength = strlen($content);
+
+ if ($originalLength < self::MIN_LENGTH) {
+ return $response;
+ }
+
+ $acceptEncoding = $request->header('Accept-Encoding', '');
+ $compressed = null;
+ $encoding = null;
+
+ // Try Brotli compression first (best compression ratio)
+ if (str_contains($acceptEncoding, 'br') && function_exists('brotli_compress')) {
+ $compressed = brotli_compress($content, self::COMPRESSION_LEVEL);
+ $encoding = 'br';
+ }
+ // Try Gzip compression
+ elseif (str_contains($acceptEncoding, 'gzip') && function_exists('gzencode')) {
+ $compressed = gzencode($content, self::COMPRESSION_LEVEL);
+ $encoding = 'gzip';
+ }
+ // Fallback to Deflate
+ elseif (str_contains($acceptEncoding, 'deflate') && function_exists('gzdeflate')) {
+ $compressed = gzdeflate($content, self::COMPRESSION_LEVEL);
+ $encoding = 'deflate';
+ }
+
+ // Check compression efficiency
+ if ($compressed !== false && $encoding) {
+ $compressionRatio = strlen($compressed) / $originalLength;
+
+ // Only use compressed content if it's significantly smaller
+ if ($compressionRatio < self::MAX_COMPRESSION_RATIO) {
+ $response->setContent($compressed);
+ $response->headers->set('Content-Encoding', $encoding);
+ $response->headers->set('Content-Length', strlen($compressed));
+ $response->headers->set('Vary', 'Accept-Encoding');
+
+ // Add compression info to headers for debugging
+ if (config('app.debug')) {
+ $response->headers->set('X-Compression-Ratio', round($compressionRatio * 100, 2) . '%');
+ $response->headers->set('X-Original-Size', $originalLength);
+ $response->headers->set('X-Compressed-Size', strlen($compressed));
+ }
+ }
+ }
+
+ return $response;
+ }
+
+ /**
+ * Determine if the response should be compressed
+ */
+ private function shouldCompress(Request $request, Response $response): bool
+ {
+ // Don't compress if already compressed
+ if ($response->headers->has('Content-Encoding')) {
+ return false;
+ }
+
+ // Don't compress file downloads
+ if ($response->headers->has('Content-Disposition')) {
+ return false;
+ }
+
+ // Only compress successful responses
+ if ($response->getStatusCode() >= 400) {
+ return false;
+ }
+
+ // Check if content type is compressible
+ $contentType = $response->headers->get('Content-Type', '');
+ foreach (self::COMPRESSIBLE_TYPES as $type) {
+ if (str_contains($contentType, $type)) {
+ return true;
+ }
+ }
+
+ return false;
+ }
+}
\ No newline at end of file
diff --git a/app/Http/Middleware/CompressionMiddleware.php b/app/Http/Middleware/CompressionMiddleware.php
new file mode 100644
index 0000000..52f3590
--- /dev/null
+++ b/app/Http/Middleware/CompressionMiddleware.php
@@ -0,0 +1,90 @@
+shouldCompress($request, $response)) {
+ return $response;
+ }
+
+ $content = $response->getContent();
+
+ // Chỉ nén nếu content size > 1KB
+ if (strlen($content) < 1024) {
+ return $response;
+ }
+
+ $acceptEncoding = $request->header('Accept-Encoding', '');
+
+ // Kiểm tra client hỗ trợ gzip
+ if (str_contains($acceptEncoding, 'gzip') && function_exists('gzencode')) {
+ $compressed = gzencode($content, 6); // Level 6 compression
+
+ if ($compressed !== false) {
+ $response->setContent($compressed);
+ $response->headers->set('Content-Encoding', 'gzip');
+ $response->headers->set('Content-Length', strlen($compressed));
+ $response->headers->set('Vary', 'Accept-Encoding');
+ }
+ }
+ // Fallback to deflate if gzip not supported
+ elseif (str_contains($acceptEncoding, 'deflate') && function_exists('gzdeflate')) {
+ $compressed = gzdeflate($content, 6);
+
+ if ($compressed !== false) {
+ $response->setContent($compressed);
+ $response->headers->set('Content-Encoding', 'deflate');
+ $response->headers->set('Content-Length', strlen($compressed));
+ $response->headers->set('Vary', 'Accept-Encoding');
+ }
+ }
+
+ return $response;
+ }
+
+ /**
+ * Determine if the response should be compressed.
+ */
+ private function shouldCompress(Request $request, Response $response): bool
+ {
+ // Chỉ nén JSON responses
+ $contentType = $response->headers->get('Content-Type', '');
+ if (!str_contains($contentType, 'application/json')) {
+ return false;
+ }
+
+ // Không nén nếu đã có Content-Encoding
+ if ($response->headers->has('Content-Encoding')) {
+ return false;
+ }
+
+ // Không nén file downloads
+ if ($response->headers->has('Content-Disposition')) {
+ return false;
+ }
+
+ // Chỉ nén response thành công
+ if ($response->getStatusCode() >= 400) {
+ return false;
+ }
+
+ return true;
+ }
+}
\ No newline at end of file
diff --git a/app/Models/Invoice.php b/app/Models/Invoice.php
index d4a6895..eafca2e 100644
--- a/app/Models/Invoice.php
+++ b/app/Models/Invoice.php
@@ -10,41 +10,58 @@ use Illuminate\Database\Eloquent\SoftDeletes;
class Invoice extends Model
{
use HasFactory, SoftDeletes;
- protected $table = 'invoice';
- public $timestamps = true;
+
+ protected $table = 'invoices';
protected $guarded = [];
+
+ protected $casts = [
+ 'due_date' => 'date',
+ 'paid_at' => 'datetime',
+ 'auto_renew' => 'boolean',
+ ];
+
public function customer()
{
- return $this->belongsTo(\App\Models\Customer::class);
- }
-
- public function payment()
- {
- return $this->belongsTo(\App\Models\Payment::class, 'payment_id', 'id');
- }
-
- public function invoiceItem()
- {
- return $this->hasMany(\App\Models\InvoiceItem::class)->with(['addons.addonItems', 'hosting', 'vps', 'os']);
+ return $this->belongsTo(Customer::class);
}
+ /** The order that originated this invoice. */
public function order()
{
- return $this->belongsTo(\App\Models\Order::class, 'id', 'invoice_id');
+ return $this->belongsTo(Order::class, 'order_id');
}
+ /** Line items on this invoice. */
+ public function invoiceItems()
+ {
+ return $this->hasMany(InvoiceItem::class, 'invoice_id');
+ }
+
+ /** Uploaded payment-proof or supporting documents. */
public function attachments()
{
- return $this->hasMany(\App\Models\InvoiceAttachment::class);
+ return $this->hasMany(InvoiceAttachment::class);
}
- public function getStatus()
+ /** Services whose active invoice is this one (updated on each renewal). */
+ public function services()
{
- return match ($this->attributes['status']) {
+ return $this->hasMany(Service::class, 'invoice_id');
+ }
+
+ // -------------------------------------------------------------------------
+ // Helpers
+ // -------------------------------------------------------------------------
+
+ public function getStatus(): string
+ {
+ return match ($this->status) {
1 => 'Chưa thanh toán',
2 => 'Đã thanh toán',
- default => 'Đã hủy'
+ 3 => 'Đã hủy',
+ 4 => 'Quá hạn',
+ default => 'Không xác định',
};
}
diff --git a/app/Models/InvoiceAddons.php b/app/Models/InvoiceAddons.php
index 552c5ec..a1cff46 100644
--- a/app/Models/InvoiceAddons.php
+++ b/app/Models/InvoiceAddons.php
@@ -5,19 +5,21 @@ namespace App\Models;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
+/**
+ * @deprecated Replaced by ServiceAddon.
+ * The invoice_addons table has been removed in the billing refactor.
+ * Use ServiceAddon for configurable resources attached to a Service.
+ */
class InvoiceAddons extends Model
{
use HasFactory;
+ protected $table = 'invoice_addons';
+
protected $guarded = [];
public function invoiceItem()
{
return $this->belongsTo(InvoiceItem::class, 'invoice_item_id');
}
-
- public function addonItems()
- {
- return $this->belongsTo(Addon::class, 'addon_id');
- }
}
diff --git a/app/Models/InvoiceItem.php b/app/Models/InvoiceItem.php
index 3ca2194..f1dd0e9 100644
--- a/app/Models/InvoiceItem.php
+++ b/app/Models/InvoiceItem.php
@@ -9,101 +9,49 @@ class InvoiceItem extends Model
{
use HasFactory;
- protected $table = 'invoice_item';
-
-
- // protected $fillable = [
- // 'invoice_id',
- // 'customer_id',
- // 'billing',
- // 'type_id',
- // 'product_id',
- // 'invoice_type',
- // 'parent_id',
- // 'relid',
- // 'description',
- // 'amount',
- // 'tax',
- // 'due_date'
- // ];
+ protected $table = 'invoice_items';
protected $guarded = [];
- public $timestamps = true;
+ // -------------------------------------------------------------------------
+ // Relationships
+ // -------------------------------------------------------------------------
+
public function invoice()
{
- return $this->belongsTo(\App\Models\Invoice::class, 'invoice_id');
+ return $this->belongsTo(Invoice::class, 'invoice_id');
}
- public function customer()
+ public function product()
{
- return $this->belongsTo(\App\Models\Customer::class);
- }
- public function type()
- {
- return $this->belongsTo(\App\Models\Type::class, 'invoice_type');
- }
- public function os()
- {
- return $this->belongsTo(Os::class, 'os_id');
+ return $this->belongsTo(Product::class, 'product_id');
}
- public function addons()
- {
- return $this->hasMany(InvoiceAddons::class, 'invoice_item_id')->with('addonItems');
- }
+ // -------------------------------------------------------------------------
+ // Helpers
+ // -------------------------------------------------------------------------
- public function hosting()
+ public function getBillingLabelAttribute(): string
{
- return $this->belongsTo(Hosting::class, 'product_id');
- }
-
- public function vps()
- {
- return $this->belongsTo(VPS::class, 'product_id');
- }
-
- // Remove the dynamic product relationship as it causes issues with eager loading
-
- public function getProductAttribute()
- {
- return match ($this->product_type) {
- 3 => $this->hosting,
- 4 => $this->vps,
- default => null,
+ return match ((int) $this->months) {
+ 0 => 'One Time',
+ 1 => 'Monthly',
+ 3 => 'Quarterly',
+ 6 => 'Semi-Annually',
+ 12 => 'Annually',
+ 24 => '2 Years',
+ 36 => '3 Years',
+ default => $this->months . ' Months',
};
}
- public function getBilling()
+ public function getTypeLabelAttribute(): string
{
- $months = $this->attributes['months'] ?? 0;
-
- return match ($months) {
- 0 => "One Time",
- 1 => "Monthly",
- 3 => "Quarterly",
- 6 => "Semi-Annually",
- 12 => "Annually",
- 24 => "2 Years",
- 36 => "3 Years",
- default => $months . " Months"
+ return match ($this->type) {
+ 'service' => 'Dịch vụ',
+ 'addon' => 'Addon',
+ 'upgrade' => 'Nâng cấp',
+ default => ucfirst($this->type),
};
}
-
- public function GetRelidProduct()
- {
- $model = null;
- switch ($this->type_id) {
- case 3:
- $model = ModuleHosting::with('server')->find($this->relid);
- break;
- case 4:
- $model = ModuleVmware::with('ipAddress')->find($this->relid);
- break;
- default:
- break;
- }
- return $model;
- }
-
}
diff --git a/app/Models/Order.php b/app/Models/Order.php
index 5bc8292..51cde55 100644
--- a/app/Models/Order.php
+++ b/app/Models/Order.php
@@ -10,62 +10,53 @@ class Order extends Model
{
use HasFactory, SoftDeletes;
- protected $table = 'order';
-
- public $timestamps = true;
-
- // protected $fillable = [
- // 'order_num',
- // 'customer_id',
- // 'date',
- // 'payment_id',
- // 'discount_id',
- // 'discount_value',
- // 'invoice_id',
- // 'user_id',
- // 'amount',
- // 'note',
- // 'client_ip',
- // 'status'
- // ];
+ protected $table = 'orders';
protected $guarded = [];
public function customer()
{
- return $this->belongsTo(\App\Models\Customer::class);
- }
- public function discount()
- {
- return $this->belongsTo(\App\Models\Discount::class);
- }
-
- public function invoice()
- {
- return $this->belongsTo(\App\Models\Invoice::class, 'invoice_id', 'id');
- }
-
- public function payment()
- {
- return $this->belongsTo(\App\Models\Payment::class, 'payment_method', 'id');
+ return $this->belongsTo(Customer::class);
}
public function user()
{
- return $this->belongsTo(\App\Models\User::class);
+ return $this->belongsTo(User::class);
}
- public function orderItem()
+ public function discount()
{
- return $this->belongsToMany(\App\Models\InvoiceItem::class, 'order_items', 'order_id', 'invoice_item_id');
+ return $this->belongsTo(Discount::class);
}
+ /** Line items that make up this order. */
public function orderItems()
{
return $this->hasMany(OrderItem::class, 'order_id');
}
- public function vmWare()
+ /** Invoice generated from this order. */
+ public function invoice()
{
- return $this->hasMany(\App\Models\ModuleVmware::class, 'order_id', 'id');
+ return $this->hasOne(Invoice::class, 'order_id');
+ }
+
+ // -------------------------------------------------------------------------
+ // Helpers
+ // -------------------------------------------------------------------------
+
+ public function getStatusLabelAttribute(): string
+ {
+ return match ($this->status) {
+ 'pending' => 'Chờ xử lý',
+ 'processing' => 'Đang xử lý',
+ 'completed' => 'Hoàn tất',
+ 'cancelled' => 'Đã hủy',
+ default => ucfirst($this->status),
+ };
+ }
+
+ public function payment()
+ {
+ return $this->belongsTo(Payment::class);
}
}
diff --git a/app/Models/OrderAddon.php b/app/Models/OrderAddon.php
index 1f30ff7..54969aa 100644
--- a/app/Models/OrderAddon.php
+++ b/app/Models/OrderAddon.php
@@ -5,21 +5,21 @@ namespace App\Models;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
+/**
+ * @deprecated Replaced by ServiceAddon.
+ * The order__order_addons table has been removed in the billing refactor.
+ * Use ServiceAddon for configurable resources attached to a Service.
+ */
class OrderAddon extends Model
{
use HasFactory;
- protected $guarded = [];
-
protected $table = 'order__order_addons';
+ protected $guarded = [];
+
public function orderItem()
{
return $this->belongsTo(OrderItem::class, 'order_item_id');
}
-
- public function addonItems()
- {
- return $this->belongsTo(Addon::class, 'addon_id');
- }
}
diff --git a/app/Models/OrderItem.php b/app/Models/OrderItem.php
index 40c20c1..1e5c580 100644
--- a/app/Models/OrderItem.php
+++ b/app/Models/OrderItem.php
@@ -10,50 +10,58 @@ class OrderItem extends Model
use HasFactory;
protected $table = 'order_items';
+
protected $guarded = [];
- public $timestamps = false;
-
+
+ protected $casts = [
+ 'addons_meta' => 'array',
+ ];
+
+ // -------------------------------------------------------------------------
+ // Relationships
+ // -------------------------------------------------------------------------
+
public function order()
{
return $this->belongsTo(Order::class, 'order_id');
}
- public function addons()
- {
- return $this->hasMany(OrderAddon::class, 'order_item_id');
- }
-
-
- public function getProductAttribute()
- {
- if ($this->product_id) {
- return Product::find($this->product_id);
- }
-
- return null;
- }
-
- /**
- * Relationship: OrderItem belongs to Product (new system)
- */
public function product()
{
return $this->belongsTo(Product::class, 'product_id');
}
- /**
- * Relationship: OrderItem has pricing info
- */
public function pricing()
{
return $this->belongsTo(ProductPricings::class, 'product_pricing_id');
}
-
-
public function os()
{
return $this->belongsTo(Os::class, 'os_id');
+ }
+ /** The provisioned service spawned from this order line. */
+ public function service()
+ {
+ return $this->hasOne(Service::class, 'order_item_id');
+ }
+
+ // -------------------------------------------------------------------------
+ // Helpers
+ // -------------------------------------------------------------------------
+
+ public function getBillingLabelAttribute(): string
+ {
+ return match ((int) $this->months) {
+ 0 => 'One Time',
+ 1 => 'Monthly',
+ 3 => 'Quarterly',
+ 6 => 'Semi-Annually',
+ 12 => 'Annually',
+ 24 => '2 Years',
+ 36 => '3 Years',
+ default => $this->months . ' Months',
+ };
}
}
diff --git a/app/Models/Service.php b/app/Models/Service.php
new file mode 100644
index 0000000..7e7ab61
--- /dev/null
+++ b/app/Models/Service.php
@@ -0,0 +1,89 @@
+ 'boolean',
+ 'started_at' => 'datetime',
+ 'expires_at' => 'datetime',
+ 'suspended_at' => 'datetime',
+ 'cancelled_at' => 'datetime',
+ ];
+
+ // -------------------------------------------------------------------------
+ // Relationships
+ // -------------------------------------------------------------------------
+
+ public function customer()
+ {
+ return $this->belongsTo(Customer::class);
+ }
+
+ public function product()
+ {
+ return $this->belongsTo(Product::class, 'product_id');
+ }
+
+ /** The order item that triggered provisioning. */
+ public function orderItem()
+ {
+ return $this->belongsTo(OrderItem::class, 'order_item_id');
+ }
+
+ /** The most recent invoice covering this service (updated on renewal). */
+ public function invoice()
+ {
+ return $this->belongsTo(Invoice::class, 'invoice_id');
+ }
+
+ /** Configurable resources / features attached to this service. */
+ public function addons()
+ {
+ return $this->hasMany(ServiceAddon::class, 'service_id');
+ }
+
+ // -------------------------------------------------------------------------
+ // Helpers
+ // -------------------------------------------------------------------------
+
+ public function isActive(): bool
+ {
+ return $this->status === 'active';
+ }
+
+ public function isExpired(): bool
+ {
+ return $this->expires_at !== null && $this->expires_at->isPast();
+ }
+
+ public function getStatusLabelAttribute(): string
+ {
+ return match ($this->status) {
+ 'pending' => 'Chờ kích hoạt',
+ 'active' => 'Đang hoạt động',
+ 'suspended' => 'Tạm ngưng',
+ 'expired' => 'Hết hạn',
+ 'cancelled' => 'Đã hủy',
+ 'terminated' => 'Đã xóa',
+ default => ucfirst($this->status),
+ };
+ }
+
+ /** Convenience: get an addon value by type. */
+ public function getAddon(string $type): ?string
+ {
+ return $this->addons->firstWhere('addon_type', $type)?->addon_value;
+ }
+}
diff --git a/app/Models/ServiceAddon.php b/app/Models/ServiceAddon.php
new file mode 100644
index 0000000..8176341
--- /dev/null
+++ b/app/Models/ServiceAddon.php
@@ -0,0 +1,24 @@
+belongsTo(Service::class, 'service_id');
+ }
+}
diff --git a/app/Repositories/Api/Invoice/InvoiceRepository.php b/app/Repositories/Api/Invoice/InvoiceRepository.php
index 7e0d2f1..10c5cbd 100644
--- a/app/Repositories/Api/Invoice/InvoiceRepository.php
+++ b/app/Repositories/Api/Invoice/InvoiceRepository.php
@@ -2,78 +2,83 @@
namespace App\Repositories\Api\Invoice;
+use App\Enums\Invoice\InvoiceStatus;
+use App\Helpers\OrderHelper;
use App\Models\Invoice;
+use App\Models\InvoiceItem;
+use App\Models\Order;
use App\Repositories\Api\BaseApiRepository;
+use Illuminate\Contracts\Pagination\LengthAwarePaginator;
class InvoiceRepository extends BaseApiRepository implements InvoiceRepositoryInterface
{
- public function getModel()
+ public function getModel(): string
{
return Invoice::class;
}
- /**
- * Lấy danh sách sản phẩm đang hoạt động
- */
- public function getInvoice(int $limit = 10, $customer = null)
+ // -------------------------------------------------------------------------
+ // getInvoice
+ // -------------------------------------------------------------------------
+
+ public function getInvoice(int $limit = 10, int $customerId = 0): LengthAwarePaginator
{
- return $this->model->where('customer_id', $customer)->paginate($limit);
+ return $this->model
+ ->where('customer_id', $customerId)
+ ->orderByDesc('created_at')
+ ->paginate($limit);
}
- public function createFromOrder($order)
+ // -------------------------------------------------------------------------
+ // createFromOrder
+ // -------------------------------------------------------------------------
+
+ public function createFromOrder(Order $order): Invoice
{
$invoice = $this->model->create([
- 'customer_id' => $order->customer_id,
- 'order_id' => $order->id,
- 'order_num' => \App\Helpers\OrderHelper::generateOrderNumber('INV'),
- 'date' => now(),
- 'due_date' => now()->addDays(3),
- 'total' => $order->amount,
- 'sub_total' => $order->amount,
- 'tax' => 0,
- 'status' => 1, // Unpaid
+ 'invoice_number' => OrderHelper::generateOrderNumber('INV'),
+ 'customer_id' => $order->customer_id,
+ 'order_id' => $order->id,
+ 'invoice_type' => 1, // new service
+ 'sub_total' => $order->total,
+ 'discount_amount' => $order->discount_amount ?? 0,
+ 'tax' => 0,
+ 'total' => $order->total,
+ 'status' => InvoiceStatus::UNPAID->value,
+ 'due_date' => now()->addDays(3)->toDateString(),
]);
- $order->update(['invoice_id' => $invoice->id]);
-
- $orderItems = $order->orderItems;
- if ($orderItems->isEmpty()) {
- $orderItems = $order->orderItems()->get();
- }
-
- foreach ($orderItems as $item) {
- $invoiceItem = \App\Models\InvoiceItem::create([
- 'invoice_id' => $invoice->id,
- // 'customer_id' removed as not in migration
- 'product_id' => $item->product_id,
- // 'description' removed as not in migration
- 'price' => $item->price ?? $order->amount,
- // 'type_id' removed, use 'product_type'
- 'product_type' => $item->product->product_type instanceof \App\Enums\Product\ProductTypeEnums
- ? $item->product->product_type->value
- : (\App\Enums\Product\ProductTypeEnums::tryFrom($item->product->product_type)?->value ?? 1),
- // 'billing' removed, map to 'months' if possible? default 0 in migration
- 'months' => match ($item->billing_cycle) {
- 'quarterly' => 3,
- 'semi_annually' => 6,
- 'annually' => 12,
- default => 1,
- },
- 'server_name' => $item->server_name,
- 'os_id' => $item->os_id,
+ // Create one invoice_item per order_item line
+ foreach ($order->orderItems as $item) {
+ InvoiceItem::create([
+ 'invoice_id' => $invoice->id,
+ 'product_id' => $item->product_id,
+ 'type' => 'service',
+ 'description' => $item->product?->name ?? 'Dịch vụ',
+ 'months' => $this->cycleToMonths($item->billing_cycle),
+ 'quantity' => $item->quantity ?? 1,
+ 'unit_price' => $item->price,
+ 'total_price' => $item->price * ($item->quantity ?? 1),
]);
-
- // Copy addons from OrderItem to InvoiceItem
- foreach ($item->addons as $addon) {
- \App\Models\InvoiceAddons::create([
- 'invoice_item_id' => $invoiceItem->id,
- 'addon_id' => $addon->addon_id,
- 'qty' => $addon->qty,
- 'price' => $addon->price // Unit price preserved from order
- ]);
- }
}
return $invoice;
}
+
+ // -------------------------------------------------------------------------
+ // Helpers
+ // -------------------------------------------------------------------------
+
+ private function cycleToMonths(?string $cycle): int
+ {
+ return match ($cycle) {
+ 'monthly' => 1,
+ 'quarterly' => 3,
+ 'semi_annually' => 6,
+ 'annually' => 12,
+ 'biennially' => 24,
+ 'triennially' => 36,
+ default => 1,
+ };
+ }
}
diff --git a/app/Repositories/Api/Invoice/InvoiceRepositoryInterface.php b/app/Repositories/Api/Invoice/InvoiceRepositoryInterface.php
index 59e4ad9..5df5a32 100644
--- a/app/Repositories/Api/Invoice/InvoiceRepositoryInterface.php
+++ b/app/Repositories/Api/Invoice/InvoiceRepositoryInterface.php
@@ -2,11 +2,20 @@
namespace App\Repositories\Api\Invoice;
+use App\Models\Invoice;
+use App\Models\Order;
use App\Repositories\BaseRepositoryInterface;
+use Illuminate\Contracts\Pagination\LengthAwarePaginator;
interface InvoiceRepositoryInterface extends BaseRepositoryInterface
{
- public function getInvoice(int $limit = 10, $customer = null);
+ /**
+ * Paginated invoice list for a customer.
+ */
+ public function getInvoice(int $limit, int $customerId): LengthAwarePaginator;
- public function createFromOrder($order);
+ /**
+ * Create an invoice (+ invoice_items) from a completed Order.
+ */
+ public function createFromOrder(Order $order): Invoice;
}
diff --git a/app/Repositories/Api/Order/OrderRepository.php b/app/Repositories/Api/Order/OrderRepository.php
index 013bb60..39b59ff 100644
--- a/app/Repositories/Api/Order/OrderRepository.php
+++ b/app/Repositories/Api/Order/OrderRepository.php
@@ -2,84 +2,88 @@
namespace App\Repositories\Api\Order;
+use App\Enums\Invoice\InvoiceStatus;
+use App\Enums\Order\OrderStatus;
use App\Models\Order;
+use App\Models\OrderItem;
use App\Repositories\Api\BaseApiRepository;
+use Illuminate\Contracts\Pagination\LengthAwarePaginator;
class OrderRepository extends BaseApiRepository implements OrderRepositoryInterface
{
- public function getModel()
+ public function getModel(): string
{
return Order::class;
}
- public function createItem($orderId, array $data)
+ // -------------------------------------------------------------------------
+ // createItem
+ // -------------------------------------------------------------------------
+
+ public function createItem(int $orderId, array $data): OrderItem
{
$addons = $data['addons'] ?? [];
- unset($data['addons']); // Remove 'addons' from data before creating OrderItem
+ unset($data['addons']);
- $orderItem = \App\Models\OrderItem::create(array_merge(
+ return OrderItem::create(array_merge(
['order_id' => $orderId],
- $data
+ $data,
+ // Preserve addon selections as JSON; read again at provisioning time
+ // to build service_addons records.
+ ['addons_meta' => !empty($addons) ? $addons : null],
));
-
- if (!empty($addons)) {
- foreach ($addons as $addonData) {
- $addon = \App\Models\Addon::find($addonData['addon_id']);
-
- if ($addon) {
- \App\Models\OrderAddon::create([
- 'order_item_id' => $orderItem->id,
- 'addon_id' => $addon->id,
- 'qty' => $addonData['qty'],
- 'price' => $addon->price // Unit price
- ]);
- }
- }
- }
-
- return $orderItem;
}
- public function processPayment($order, $method)
+ // -------------------------------------------------------------------------
+ // processPayment
+ // -------------------------------------------------------------------------
+
+ public function processPayment(Order $order, string $method): Order
{
- // Update Order Status
$order->update([
- 'status' => \App\Enums\Order\OrderStatus::ACTIVE->value,
+ 'status' => OrderStatus::ACTIVE->value,
+ 'payment_method' => $method,
]);
- // Update Invoice Status
if ($order->invoice) {
$order->invoice->update([
- 'status' => 2, // Paid
- 'date_paid' => now()
+ 'status' => InvoiceStatus::PAID->value,
+ 'payment_method' => $method,
+ 'paid_at' => now(),
]);
}
- return $order;
+ return $order->fresh(['orderItems', 'invoice', 'customer']);
}
- public function hasPendingOrder($customerId)
+ // -------------------------------------------------------------------------
+ // hasPendingOrder
+ // -------------------------------------------------------------------------
+
+ public function hasPendingOrder(int $customerId): bool
{
- return $this->model->where('customer_id', $customerId)
- ->where('status', \App\Enums\Order\OrderStatus::PENDING->value)
+ return $this->model
+ ->where('customer_id', $customerId)
+ ->where('status', OrderStatus::PENDING->value)
->exists();
}
- public function getOrders($customerId, array $filters)
+ // -------------------------------------------------------------------------
+ // getOrders
+ // -------------------------------------------------------------------------
+
+ public function getOrders(int $customerId, array $filters): LengthAwarePaginator
{
$query = $this->model->where('customer_id', $customerId);
- if (isset($filters['total_price'])) {
- $query->where('amount', $filters['total_price']);
+ if (!empty($filters['total_price'])) {
+ $query->where('total', $filters['total_price']);
}
- if (isset($filters['pending'])) {
- // If pending=1 or true, filter by PENDING status
- if (filter_var($filters['pending'], FILTER_VALIDATE_BOOLEAN)) {
- $query->where('status', \App\Enums\Order\OrderStatus::PENDING->value);
- }
+ if (!empty($filters['pending']) && filter_var($filters['pending'], FILTER_VALIDATE_BOOLEAN)) {
+ $query->where('status', OrderStatus::PENDING->value);
}
- return $query->orderBy('created_at', 'desc')->paginate(10);
+ return $query->orderByDesc('created_at')->paginate(10);
}
}
diff --git a/app/Repositories/Api/Order/OrderRepositoryInterface.php b/app/Repositories/Api/Order/OrderRepositoryInterface.php
index 4b3edf2..ad0add0 100644
--- a/app/Repositories/Api/Order/OrderRepositoryInterface.php
+++ b/app/Repositories/Api/Order/OrderRepositoryInterface.php
@@ -2,15 +2,31 @@
namespace App\Repositories\Api\Order;
+use App\Models\Order;
+use App\Models\OrderItem;
use App\Repositories\BaseRepositoryInterface;
+use Illuminate\Contracts\Pagination\LengthAwarePaginator;
interface OrderRepositoryInterface extends BaseRepositoryInterface
{
- public function createItem($orderId, array $data);
+ /**
+ * Create an order item for the given order.
+ * Addon selections are serialised into addons_meta JSON.
+ */
+ public function createItem(int $orderId, array $data): OrderItem;
- public function processPayment($order, $method);
+ /**
+ * Mark order as completed, update the related invoice as paid.
+ */
+ public function processPayment(Order $order, string $method): Order;
- public function hasPendingOrder($customerId);
+ /**
+ * Return true if the customer has any pending orders.
+ */
+ public function hasPendingOrder(int $customerId): bool;
- public function getOrders($customerId, array $filters);
+ /**
+ * Paginated order list for a customer, with optional filters.
+ */
+ public function getOrders(int $customerId, array $filters): LengthAwarePaginator;
}
diff --git a/app/Repositories/Invoice/InvoiceRepository.php b/app/Repositories/Invoice/InvoiceRepository.php
index 73a9915..288bd42 100644
--- a/app/Repositories/Invoice/InvoiceRepository.php
+++ b/app/Repositories/Invoice/InvoiceRepository.php
@@ -29,7 +29,7 @@ class InvoiceRepository extends BaseRepository implements InvoiceRepositoryInter
$model = $model->where("customer_id", $data['customer_id']);
}
if (isset($data['payment_method']) && $data['payment_method'] !== 'full') {
- $model = $model->where("payment_id", $data['payment_method']);
+ $model = $model->where("payment_method", $data['payment_method']);
}
if (isset($data['invoice_id'])) {
$model = $model->where("invoice_id", $data['invoice_id']);
diff --git a/app/Repositories/Order/OrderCachedRepository.php b/app/Repositories/Order/OrderCachedRepository.php
index 21b60ac..1c06638 100644
--- a/app/Repositories/Order/OrderCachedRepository.php
+++ b/app/Repositories/Order/OrderCachedRepository.php
@@ -54,4 +54,12 @@ class OrderCachedRepository extends BaseCachedRepository implements OrderReposit
$this->clearCache();
return $this;
}
+
+ public function findByOrderNum(string $orderNum)
+ {
+ $cacheKey = $this->getCacheKey(md5(json_encode(['findByOrderNum' => $orderNum])));
+ return Cache::remember($cacheKey, $this->cacheTime, function () use ($orderNum) {
+ return $this->orderRepository->findByOrderNum($orderNum);
+ });
+ }
}
diff --git a/app/Repositories/Order/OrderRepository.php b/app/Repositories/Order/OrderRepository.php
index b5dbe21..b2d20fe 100644
--- a/app/Repositories/Order/OrderRepository.php
+++ b/app/Repositories/Order/OrderRepository.php
@@ -133,4 +133,17 @@ class OrderRepository extends BaseRepository implements OrderRepositoryInterface
}
return $this->model->where($key, $value)->update($data);
}
+
+ public function findByOrderNum(string $orderNum)
+ {
+ return Order::with([
+ 'orderItems.product',
+ 'orderItems.os',
+ 'user',
+ 'customer',
+ 'payment',
+ 'discount',
+ 'invoice',
+ ])->where('order_num', $orderNum)->first();
+ }
}
diff --git a/app/Repositories/Order/OrderRepositoryInterface.php b/app/Repositories/Order/OrderRepositoryInterface.php
index e5dbd1f..225bff0 100644
--- a/app/Repositories/Order/OrderRepositoryInterface.php
+++ b/app/Repositories/Order/OrderRepositoryInterface.php
@@ -7,4 +7,5 @@ interface OrderRepositoryInterface extends BaseRepositoryInterface{
public function filter(array $data = [],array $relationships = [],array $withCount = [],array $select = ['*'], bool $returnQuery = false);
public function findWithRelationships(int $id = 0, array $relationships = [],array $withCount = [], array $withSum = []);
public function updateBy(string $key = '',string|int $value = '',array $data =[]);
+ public function findByOrderNum(string $orderNum);
}
\ No newline at end of file
diff --git a/app/Services/Api/Order/OrderService.php b/app/Services/Api/Order/OrderService.php
index 63ff610..4211de6 100644
--- a/app/Services/Api/Order/OrderService.php
+++ b/app/Services/Api/Order/OrderService.php
@@ -2,92 +2,113 @@
namespace App\Services\Api\Order;
-use App\Repositories\Api\Service\ServiceRepository;
-use App\Repositories\Api\Invoice\InvoiceRepository;
-use App\Repositories\Api\Order\OrderRepository;
-use Illuminate\Support\Facades\DB;
use App\Enums\Order\OrderStatus;
-use Exception;
+use App\Helpers\OrderHelper;
use App\Jobs\VirtualMachine\Create\CreateVMJobs;
-use App\Models\Log as CustomerLog;
+use App\Models\Addon;
+use App\Models\Log as CustomerActivityLog;
use App\Models\ModuleIpAddress;
use App\Models\ModuleVmware;
+use App\Models\Order;
+use App\Models\OrderItem;
use App\Models\Os;
+use App\Models\Service;
+use App\Models\ServiceAddon;
+use App\Repositories\Api\Invoice\InvoiceRepository;
+use App\Repositories\Api\Order\OrderRepository;
+use App\Repositories\Api\Service\ServiceRepository;
+use Exception;
+use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Log;
class OrderService
{
public function __construct(
- protected OrderRepository $orderRepo,
+ protected OrderRepository $orderRepo,
protected ServiceRepository $productRepo,
- protected InvoiceRepository $invoiceRepo
+ protected InvoiceRepository $invoiceRepo,
) {}
- public function create(array $data, $customer)
+ // =========================================================================
+ // create — Step 1: Customer places an order
+ // =========================================================================
+
+ /**
+ * Validate → calculate price → persist Order + OrderItem + Invoice.
+ *
+ * @return array{order: Order, invoice: \App\Models\Invoice}
+ * @throws Exception
+ */
+ public function create(array $data, $customer): array
{
- // Check for existing pending orders
if ($this->orderRepo->hasPendingOrder($customer->id)) {
throw new Exception('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 = $this->productRepo->find($data['product_id']);
-
if (!$product) {
- throw new Exception('Sản phẩm không tồn tại');
+ throw new Exception('Sản phẩm không tồn tại.');
}
- $price = $this->productRepo->getPricing($product->id, $data['billing_cycle']);
-
- if (is_null($price)) {
- throw new Exception('Không tìm thấy giá cho sản phẩm này');
+ $basePrice = $this->productRepo->getPricing($product->id, $data['billing_cycle']);
+ if (is_null($basePrice)) {
+ throw new Exception('Không tìm thấy giá cho chu kỳ thanh toán này.');
}
- // Tính tổng tiền bao gồm addons
- $addonsPrice = 0;
- if (!empty($data['addons'])) {
- foreach ($data['addons'] as $addonData) {
- $addon = \App\Models\Addon::find($addonData['addon_id']);
- if ($addon) {
- $addonsPrice += $addon->price * ($addonData['qty'] ?? 1);
- }
- }
+ // Resolve addons → enriched meta with price & subtotal
+ $addonsMeta = $this->resolveAddons($data['addons'] ?? []);
+ $addonsPrice = array_sum(array_column($addonsMeta, 'subtotal'));
+ $total = $basePrice + $addonsPrice;
+
+ if ($customer->total < $total) {
+ throw new Exception(sprintf(
+ 'Số dư không đủ. Hiện có: %s VNĐ, cần: %s VNĐ.',
+ number_format($customer->total),
+ number_format($total)
+ ));
}
- $totalPrice = $price + $addonsPrice;
-
- // Kiểm tra số dư tài khoản (cột total) trước khi tạo đơn
- if ($customer->total < $totalPrice) {
- throw new Exception('Số dư không đủ để đặt hàng. Vui lòng nạp thêm tiền. Số dư hiện tại: ' . number_format($customer->total) . ' VNĐ, cần: ' . number_format($totalPrice) . ' VNĐ');
- }
-
- return DB::transaction(function () use ($data, $customer, $product, $price, $totalPrice) {
-
+ return DB::transaction(function () use ($data, $customer, $product, $basePrice, $total, $addonsMeta) {
$order = $this->orderRepo->create([
- 'order_num' => \App\Helpers\OrderHelper::generateOrderNumber(),
- 'customer_id' => $customer->id,
- 'amount' => $price,
- 'status' => OrderStatus::PENDING->value
+ 'order_num' => OrderHelper::generateOrderNumber(),
+ 'customer_id' => $customer->id,
+ 'sub_total' => $basePrice,
+ 'discount_amount' => 0,
+ 'total' => $total,
+ 'status' => OrderStatus::PENDING->value,
+ 'client_ip' => request()?->ip(),
]);
$this->orderRepo->createItem($order->id, [
- 'product_id' => $product->id,
+ 'product_id' => $product->id,
'billing_cycle' => $data['billing_cycle'],
- 'server_name' => $data['server_name'] ?? null,
- 'os_id' => $data['os_id'] ?? null,
- 'price' => $price,
- 'addons' => $data['addons'] ?? []
+ 'months' => $this->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
- ];
+ return ['order' => $order, 'invoice' => $invoice];
});
}
- public function pay($order, string $method)
+ // =========================================================================
+ // pay — Step 2: Customer pays the pending order
+ // =========================================================================
+
+ /**
+ * Debit customer balance → mark Order+Invoice paid → provision Service records.
+ *
+ * @throws Exception
+ */
+ public function pay(Order $order, string $method): Order
{
if (in_array($order->status, [OrderStatus::ACTIVE->value, OrderStatus::PROCESSING->value], true)) {
throw new Exception('Đơn hàng đã được thanh toán hoặc đang xử lý.');
@@ -96,105 +117,229 @@ class OrderService
return DB::transaction(function () use ($order, $method) {
$customer = $order->customer;
- // Kiểm tra số dư và trừ tiền
- if ($customer->total < $order->amount) {
- throw new Exception('Số dư không đủ để thanh toán. Vui lòng nạp thêm tiền. Số dư hiện tại: ' . number_format($customer->total) . ' VNĐ, cần: ' . number_format($order->amount) . ' VNĐ');
+ if ($customer->total < $order->total) {
+ throw new Exception(sprintf(
+ 'Số dư không đủ. Hiện có: %s VNĐ, cần: %s VNĐ.',
+ number_format($customer->total),
+ number_format($order->total)
+ ));
}
- $customer->total -= $order->amount;
+ // Debit balance
+ $customer->total -= $order->total;
$customer->save();
- CustomerLog::create([
- 'agent' => 'api',
- 'client_ip' => request()?->ip(),
+ CustomerActivityLog::create([
+ 'agent' => 'api',
+ 'client_ip' => request()?->ip(),
'customer_id' => $customer->id,
- 'description' => 'Thanh toán đơn hàng #' . $order->order_num
- . ' thành công. Số tiền: ' . number_format((int) $order->amount) . ' VNĐ'
- . '. Số dư sau thanh toán: ' . number_format((int) $customer->total) . ' VNĐ',
+ 'description' => sprintf(
+ 'Thanh toán đơn hàng #%s. Số tiền: %s VNĐ. Số dư còn: %s VNĐ.',
+ $order->order_num,
+ number_format($order->total),
+ number_format($customer->total)
+ ),
]);
- // Cho phép thanh toán lại đơn đã hủy bằng cách đưa về pending trước khi process.
+ // Allow re-paying a cancelled order
if ($order->status === OrderStatus::CANCELLED->value) {
- $order->status = OrderStatus::PENDING->value;
- $order->save();
+ $order->update(['status' => OrderStatus::PENDING->value]);
}
+ // Mark order → ACTIVE, invoice → PAID
$processedOrder = $this->orderRepo->processPayment($order, $method);
- // Sau khi thanh toán, tạo module_vmware rồi dispatch CreateVMJobs.
- foreach ($order->orderItems as $item) {
- if (empty($item->product_id) || empty($item->os_id)) {
- Log::warning('Skip VM job: product_id/os_id is missing', [
- 'order_id' => $order->id,
- 'order_item_id' => $item->id,
- 'product_id' => $item->product_id,
- 'os_id' => $item->os_id,
- ]);
- continue;
- }
-
- try {
- $os = Os::with('osGroup.host')->find($item->os_id);
-
- if (!$os || !$os->osGroup || !$os->osGroup->module_host_id) {
- Log::warning('Skip VM job: không tìm thấy OS/OsGroup/Host', [
- 'order_id' => $order->id,
- 'order_item_id' => $item->id,
- 'os_id' => $item->os_id,
- ]);
- continue;
- }
-
- $ip = ModuleIpAddress::where('module_host_id', $os->osGroup->module_host_id)
- ->where('status', 0)
- ->lockForUpdate()
- ->first();
-
- if (!$ip) {
- Log::warning('Skip VM job: không còn IP khả dụng', [
- 'order_id' => $order->id,
- 'module_host_id' => $os->osGroup->module_host_id,
- ]);
- continue;
- }
-
- $ip->update(['status' => 1, 'vm_name' => $item->server_name ?? 'VM-' . $order->id]);
-
- $moduleVmware = ModuleVmware::create([
- 'name' => $item->server_name ?? 'VM-' . $order->id,
- 'module_ip_address_id' => $ip->id,
- 'port' => 22,
- 'order_id' => $order->id,
- 'first_payment' => $order->amount,
- 'next_payment' => $order->amount,
- 'next_due_date' => null,
- 'next_invoice' => 0,
- 'user' => 'Administrator',
- 'password' => '',
- 'hostname' => $item->server_name ?? 'vm-' . $order->id,
- 'vmware_id' => null,
- 'os_id' => $item->os_id,
- 'customer_id' => $order->customer_id,
- 'status' => 0,
- ]);
-
- dispatch(new CreateVMJobs($moduleVmware->id))->delay(now()->addSeconds(10));
-
- Log::info('Dispatched CreateVMJobs', [
- 'order_id' => $order->id,
- 'module_vmware_id' => $moduleVmware->id,
- 'ip' => $ip->ip,
- ]);
- } catch (\Exception $e) {
- Log::error('Lỗi khi tạo module_vmware hoặc dispatch job', [
- 'order_id' => $order->id,
- 'order_item_id' => $item->id,
- 'error' => $e->getMessage(),
- ]);
- }
+ // Provision a Service for each order item
+ foreach ($processedOrder->orderItems as $item) {
+ $this->provisionService($processedOrder, $item, $customer);
}
return $processedOrder;
});
}
+
+ // =========================================================================
+ // Private — provisionService
+ // =========================================================================
+
+ private function provisionService(Order $order, OrderItem $item, $customer): void
+ {
+ $invoice = $order->invoice;
+ $months = $item->months ?: $this->cycleToMonths($item->billing_cycle);
+ $expiresAt = now()->addMonths($months);
+
+ $service = Service::create([
+ 'customer_id' => $customer->id,
+ 'product_id' => $item->product_id,
+ 'order_item_id' => $item->id,
+ 'invoice_id' => $invoice?->id,
+ 'server_name' => $item->server_name,
+ 'status' => 'pending',
+ 'auto_renew' => false,
+ 'started_at' => now(),
+ 'expires_at' => $expiresAt,
+ ]);
+
+ $this->attachServiceAddons($service, $item);
+
+ // VPS: requires product + os_id → dispatch VM creation job
+ if ($item->product_id && $item->os_id) {
+ $this->dispatchVmJob($order, $item, $service, $customer);
+ } else {
+ // Hosting / Domain / non-VM services activate immediately
+ $service->update(['status' => 'active']);
+ }
+ }
+
+ // -------------------------------------------------------------------------
+
+ /**
+ * Create ServiceAddon rows from:
+ * 1. Base product specs (cpu, ram, disk, bandwidth)
+ * 2. Customer's configurable addon selections stored in addons_meta
+ */
+ private function attachServiceAddons(Service $service, OrderItem $item): void
+ {
+ // 1. Base product specs
+ $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) $value,
+ 'price' => 0,
+ ]);
+ }
+ }
+ }
+
+ // 2. Customer-selected addons
+ $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) ($meta['qty'] ?? 1),
+ 'price' => (int) ($meta['price'] ?? $addon->price),
+ ]);
+ }
+ }
+
+ // -------------------------------------------------------------------------
+
+ private function dispatchVmJob(Order $order, OrderItem $item, Service $service, $customer): void
+ {
+ try {
+ $os = Os::with('osGroup.host')->find($item->os_id);
+
+ if (!$os?->osGroup?->module_host_id) {
+ Log::warning('dispatchVmJob: OS/OsGroup/Host not found', [
+ 'order_id' => $order->id,
+ 'os_id' => $item->os_id,
+ ]);
+ return;
+ }
+
+ $ip = ModuleIpAddress::where('module_host_id', $os->osGroup->module_host_id)
+ ->where('status', 0)
+ ->lockForUpdate()
+ ->first();
+
+ if (!$ip) {
+ Log::warning('dispatchVmJob: no available IP', [
+ 'order_id' => $order->id,
+ 'module_host_id' => $os->osGroup->module_host_id,
+ ]);
+ return;
+ }
+
+ $vmName = $item->server_name ?? ('VM-' . $order->id);
+ $ip->update(['status' => 1, 'vm_name' => $vmName]);
+
+ $moduleVmware = ModuleVmware::create([
+ 'name' => $vmName,
+ 'module_ip_address_id' => $ip->id,
+ 'port' => 22,
+ 'order_id' => $order->id,
+ 'first_payment' => $order->total,
+ 'next_payment' => $order->total,
+ 'next_due_date' => $service->expires_at,
+ 'next_invoice' => 0,
+ 'user' => 'Administrator',
+ 'password' => '',
+ 'hostname' => strtolower($vmName),
+ 'vmware_id' => null,
+ 'os_id' => $item->os_id,
+ 'customer_id' => $customer->id,
+ 'status' => 0,
+ ]);
+
+ // Store module_vmware.id as vm_id on Service for quick lookup
+ $service->update(['vm_id' => (string) $moduleVmware->id]);
+
+ dispatch(new CreateVMJobs($moduleVmware->id))->delay(now()->addSeconds(10));
+
+ Log::info('dispatchVmJob: CreateVMJobs dispatched', [
+ 'order_id' => $order->id,
+ 'service_id' => $service->id,
+ 'module_vmware_id' => $moduleVmware->id,
+ ]);
+ } catch (Exception $e) {
+ Log::error('dispatchVmJob: exception', [
+ 'order_id' => $order->id,
+ 'error' => $e->getMessage(),
+ ]);
+ }
+ }
+
+ // =========================================================================
+ // Helpers
+ // =========================================================================
+
+ private function cycleToMonths(?string $cycle): int
+ {
+ return match ($cycle) {
+ 'monthly' => 1,
+ 'quarterly' => 3,
+ 'semi_annually' => 6,
+ 'annually' => 12,
+ 'biennially' => 24,
+ 'triennially' => 36,
+ default => 1,
+ };
+ }
+
+ /**
+ * Enrich raw addon entries with resolved price & subtotal.
+ *
+ * Input: [['addon_id' => 1, 'qty' => 2], ...]
+ * Output: [['addon_id' => 1, 'qty' => 2, 'price' => 50000, 'subtotal' => 100000], ...]
+ */
+ private function resolveAddons(array $raw): array
+ {
+ $resolved = [];
+ foreach ($raw as $entry) {
+ $addon = Addon::find($entry['addon_id'] ?? null);
+ if (!$addon) {
+ continue;
+ }
+ $qty = max(1, (int) ($entry['qty'] ?? 1));
+ $resolved[] = [
+ 'addon_id' => $addon->id,
+ 'qty' => $qty,
+ 'price' => $addon->price,
+ 'subtotal' => $addon->price * $qty,
+ ];
+ }
+ return $resolved;
+ }
}
diff --git a/app/Services/CompressionService.php b/app/Services/CompressionService.php
new file mode 100644
index 0000000..5d89c6f
--- /dev/null
+++ b/app/Services/CompressionService.php
@@ -0,0 +1,84 @@
+header('Accept-Encoding', '');
+
+ return [
+ 'supports_gzip' => str_contains($acceptEncoding, 'gzip'),
+ 'supports_deflate' => str_contains($acceptEncoding, 'deflate'),
+ 'supports_brotli' => str_contains($acceptEncoding, 'br'),
+ 'raw_header' => $acceptEncoding,
+ ];
+ }
+
+ /**
+ * Get compression info for debugging
+ */
+ public static function getCompressionInfo(string $content): array
+ {
+ $originalSize = strlen($content);
+ $info = ['original_size' => $originalSize];
+
+ if (function_exists('gzencode')) {
+ $gzipped = gzencode($content, 6);
+ if ($gzipped !== false) {
+ $gzippedSize = strlen($gzipped);
+ $info['gzip'] = [
+ 'compressed_size' => $gzippedSize,
+ 'ratio' => round(($gzippedSize / $originalSize) * 100, 2) . '%',
+ 'savings' => round((1 - $gzippedSize / $originalSize) * 100, 2) . '%'
+ ];
+ }
+ }
+
+ if (function_exists('gzdeflate')) {
+ $deflated = gzdeflate($content, 6);
+ if ($deflated !== false) {
+ $deflatedSize = strlen($deflated);
+ $info['deflate'] = [
+ 'compressed_size' => $deflatedSize,
+ 'ratio' => round(($deflatedSize / $originalSize) * 100, 2) . '%',
+ 'savings' => round((1 - $deflatedSize / $originalSize) * 100, 2) . '%'
+ ];
+ }
+ }
+
+ if (function_exists('brotli_compress')) {
+ $brotlied = brotli_compress($content, 6);
+ if ($brotlied !== false) {
+ $brotliedSize = strlen($brotlied);
+ $info['brotli'] = [
+ 'compressed_size' => $brotliedSize,
+ 'ratio' => round(($brotliedSize / $originalSize) * 100, 2) . '%',
+ 'savings' => round((1 - $brotliedSize / $originalSize) * 100, 2) . '%'
+ ];
+ }
+ }
+
+ return $info;
+ }
+
+ /**
+ * Format bytes to human readable format
+ */
+ public static function formatBytes(int $bytes, int $precision = 2): string
+ {
+ $units = ['B', 'KB', 'MB', 'GB', 'TB'];
+
+ for ($i = 0; $bytes > 1024 && $i < count($units) - 1; $i++) {
+ $bytes /= 1024;
+ }
+
+ return round($bytes, $precision) . ' ' . $units[$i];
+ }
+}
\ No newline at end of file
diff --git a/app/Services/OrderService.php b/app/Services/OrderService.php
new file mode 100644
index 0000000..c4da297
--- /dev/null
+++ b/app/Services/OrderService.php
@@ -0,0 +1,221 @@
+status !== 1) {
+ throw new \InvalidArgumentException('Tài khoản khách hàng không hoạt động. Không thể tạo đơn hàng.');
+ }
+
+ $paymentRecord = Payment::find((int) $data['payment_method']);
+ $isBankTransfer = $paymentRecord && (int) $paymentRecord->type === 1;
+
+ [$productItems, $relIdData, $total] = $this->buildProductItems($data);
+
+ $invoiceData = [
+ 'date' => Carbon::today()->toDateString(),
+ 'due_date' => Carbon::today()->toDateString(),
+ 'date_paid' => (int) $data['status'] === 1 ? Carbon::today()->toDateString() : null,
+ 'sub_total' => $total,
+ 'tax' => $total * (10 / 100),
+ 'total' => $total + ($total * (10 / 100)),
+ 'payment_method' => $paymentRecord?->id,
+ 'customer_id' => ($data['create_invoice'] ?? false) ? $data['customer_id'] : 0,
+ 'status' => $data['status'],
+ ];
+
+ $orderData = [
+ 'order_num' => Helper::generateUniqueCode(),
+ 'customer_id' => $data['customer_id'],
+ 'discount_id' => $data['discount_id'] ?? null,
+ 'sub_total' => $total,
+ 'total' => $total + ($total * (10 / 100)),
+ 'payment_method' => $paymentRecord?->code ?? null,
+ 'payment_proof' => $data['payment_proof'] ?? null,
+ 'user_id' => Auth::guard('admin')->id(),
+ 'status' => $data['status'],
+ 'client_ip' => request()->getClientIp(),
+ ];
+
+ return DB::transaction(function () use ($invoiceData, $orderData, $productItems, $relIdData, $data, $total, $isBankTransfer) {
+ $invoice = $this->invoiceRepository->create($invoiceData);
+
+ $productItems = array_map(fn ($item) => array_merge($item, ['invoice_id' => $invoice->id]), $productItems);
+
+ if ($data['create_invoice'] ?? false) {
+ $orderData['invoice_id'] = $invoice->id;
+ }
+
+ $order = $this->orderRepository->create($orderData);
+
+ $relIdData = array_map(function ($item) use ($order) {
+ $item['data']['order_id'] = $order->id;
+ return $item;
+ }, $relIdData);
+
+ $this->createModules($relIdData);
+
+ $invoiceItemData = array_map(
+ fn ($item) => \Illuminate\Support\Arr::except($item, ['billing_cycle', 'code']),
+ $productItems
+ );
+ $this->invoiceItemRepository->insert($invoiceItemData);
+
+ $now = now();
+ OrderItem::insert(array_map(fn ($item) => [
+ 'order_id' => $order->id,
+ 'product_id' => $item['product_id'],
+ 'billing_cycle' => $item['billing_cycle'],
+ 'months' => $item['months'],
+ 'os_id' => $item['os_id'],
+ 'quantity' => $item['quantity'],
+ 'server_name' => $item['server_name'],
+ 'price' => $item['price'],
+ 'created_at' => $now,
+ 'updated_at' => $now,
+ ], $productItems));
+
+ $vpsTypes = ['vps', 'cloud'];
+ foreach (OrderItem::where('order_id', $order->id)->get() as $orderItem) {
+ if (in_array($orderItem->product?->product_type, $vpsTypes)) {
+ CreateVirtualMachineJob::dispatch($orderItem)->delay(now()->addSeconds(10));
+ }
+ }
+
+ if (!$isBankTransfer) {
+ $orderTotal = (int) ($total + ($total * (10 / 100)));
+ $customer = Customer::lockForUpdate()->find($data['customer_id']);
+
+ if ($customer->total < $orderTotal) {
+ throw new \Exception('Số dư tài khoản không đủ để thanh toán đơn hàng.');
+ }
+
+ $customer->decrement('total', $orderTotal);
+ }
+
+ return $order;
+ });
+ }
+
+ private function buildProductItems(array $data): array
+ {
+ $total = 0;
+ $productItems = [];
+ $relIdData = [];
+
+ foreach ($data['product'] as $key => $item) {
+ $product = \App\Models\Product::with('category')->find($item['product_id']);
+
+ if (!$product) {
+ throw new \InvalidArgumentException("Sản phẩm không tồn tại (ID: {$item['product_id']}).");
+ }
+
+ $moduleType = match ($product->product_type) {
+ 'hosting' => 3,
+ 'vps' => 4,
+ default => 0,
+ };
+
+ $billingDetails = $this->calculateBillingDetails($item['billing']);
+ $priceSelect = $item['price_custom'] ?? $product->price_sale ?? $product->price;
+
+ $billingCycle = ProductBillingCycle::fromLegacyCode((int) $item['billing']);
+ $generalCode = Helper::generateUniqueCode();
+
+ $productItems[$key] = [
+ 'product_id' => $product->id,
+ 'product_type' => $product->product_type,
+ 'months' => $billingCycle->months(),
+ 'billing_cycle' => $billingCycle->value,
+ 'os_id' => $item['os_id'] ?? null,
+ 'quantity' => (int) ($item['quantity'] ?? 1),
+ 'server_name' => $item['domain'] ?? null,
+ 'price' => (int) $priceSelect,
+ 'invoice_type' => 1,
+ 'code' => $generalCode,
+ ];
+
+ $relIdData[$key] = [
+ 'type' => $moduleType,
+ 'code' => $generalCode,
+ 'data' => [
+ 'customer_id' => $data['customer_id'],
+ 'status' => 0,
+ 'name' => $item['domain'] ?? $product->name,
+ 'first_payment' => $priceSelect * $billingDetails['billing'],
+ 'next_payment' => ($item['price_custom'] ?? $product->price) * $billingDetails['billing'],
+ ...$billingDetails,
+ ],
+ ];
+
+ if ($item['quantity'] > 1) {
+ for ($i = 1; $i < $item['quantity']; $i++) {
+ $newCode = Helper::generateUniqueCode();
+ $productItems[] = array_merge($productItems[$key], ['code' => $newCode]);
+ $relIdData[] = array_merge($relIdData[$key], ['code' => $newCode]);
+ }
+ }
+
+ $total += $item['quantity'] * ($item['price_custom'] ?? ($product->price_sale ?? $product->price)) * $billingDetails['billing'];
+ }
+
+ return [$productItems, $relIdData, $total];
+ }
+
+ private function createModules(array $relIdData): void
+ {
+ foreach ($relIdData as $item) {
+ match ($item['type']) {
+ 3 => $this->moduleHostingRepository->create($item['data']),
+ 4 => $this->moduleVmwareRepository->create(array_merge($item['data'], ['status' => 2])),
+ default => null,
+ };
+ }
+ }
+
+ private function calculateBillingDetails(int|string $billing): array
+ {
+ $months = match ((int) $billing) {
+ 2 => 1,
+ 3 => 3,
+ 4 => 6,
+ 5 => 12,
+ default => 0,
+ };
+
+ return [
+ 'next_due_date' => $months ? Carbon::today()->addMonths($months)->timestamp : null,
+ 'next_invoice' => $months ? Carbon::today()->addMonths($months)->timestamp : null,
+ 'billing' => $months ?: 1,
+ ];
+ }
+}
diff --git a/database/migrations/2014_10_12_000000_create_users_table.php b/database/migrations/2014_10_12_000000_create_users_table.php
index 9e4de90..5f2c7bb 100644
--- a/database/migrations/2014_10_12_000000_create_users_table.php
+++ b/database/migrations/2014_10_12_000000_create_users_table.php
@@ -18,13 +18,16 @@ return new class extends Migration {
$table->string('lastname', 255)->nullable();
$table->string('username', 100)->nullable();
$table->string('email', 100)->nullable();
+ $table->date('join_date')->nullable();
+ $table->date('birthday')->nullable();
+ $table->string('address')->nullable();
+ $table->string('phone', 20)->nullable();
$table->timestamp('email_verified_at')->nullable();
$table->string('password')->nullable();
$table->tinyInteger('status')->default(1);
$table->rememberToken();
$table->timestamps();
});
-
}
/**
diff --git a/database/migrations/2024_01_31_152911_domain.php b/database/migrations/2024_01_31_152911_domain.php
deleted file mode 100644
index 1f64704..0000000
--- a/database/migrations/2024_01_31_152911_domain.php
+++ /dev/null
@@ -1,43 +0,0 @@
-id()->autoIncrement();
- $table->string('name',100)->nullable();
- $table->integer('price')->nullable();
- $table->integer('price_sale')->nullable();
- $table->integer('renewal')->nullable();
- $table->integer('transfer')->nullable();
- $table->tinyInteger('auto_reg')->default(0);
- $table->tinyInteger('type_id')->default(0)->index();
- $table->tinyInteger('status')->default(1);
- $table->timestamps();
- });
- }
-
- /**
- * Reverse the migrations.
- *
- * @return void
- */
- public function down()
- {
- Schema::table('domain',function (Blueprint $table){
- $table->dropForeign('group_id');
- $table->drop();
- });
- }
-};
diff --git a/database/migrations/2024_01_31_155307_discount__customer.php b/database/migrations/2024_01_31_155307_discount__customer.php
index fc57528..9c587bf 100644
--- a/database/migrations/2024_01_31_155307_discount__customer.php
+++ b/database/migrations/2024_01_31_155307_discount__customer.php
@@ -14,9 +14,16 @@ return new class extends Migration
public function up()
{
//
- Schema::create('discount_customer',function (Blueprint $table) {
- $table->bigInteger('discount_id')->default(0)->index();
- $table->bigInteger('customer_id')->default(0)->index();
+ Schema::create('discount_customer', function (Blueprint $table) {
+ $table->foreignId('discount_id')
+ ->constrained('discount')
+ ->cascadeOnDelete();
+
+ $table->foreignId('customer_id')
+ ->constrained('user')
+ ->cascadeOnDelete();
+
+ $table->primary(['discount_id', 'customer_id']);
});
}
@@ -27,9 +34,6 @@ return new class extends Migration
*/
public function down()
{
- Schema::table('discount_customer',function (Blueprint $table) {
- $table->dropForeign(['discount_id','customer_id']);
- $table->drop();
- });
+ Schema::dropIfExists('discount_customer');
}
};
diff --git a/database/migrations/2024_01_31_155621_order.php b/database/migrations/2024_01_31_155621_order.php
deleted file mode 100644
index e39224a..0000000
--- a/database/migrations/2024_01_31_155621_order.php
+++ /dev/null
@@ -1,47 +0,0 @@
-id();
- $table->string('order_num', 255)->unique()->nullable();
- $table->bigInteger('customer_id')->default(0)->index();
- $table->integer('date')->nullable();
- $table->integer('user_id')->default(0);
- $table->integer('discount_id')->default(0);
- $table->integer('invoice_id')->default(0);
- $table->integer('discount_value')->nullable();
- $table->integer('amount')->nullable();
- $table->text('note')->nullable();
- $table->string('client_ip', 100)->nullable();
- $table->tinyInteger('status')->default(0);
- $table->timestamps();
- $table->softDeletes();
- });
- }
-
- /**
- * Reverse the migrations.
- *
- * @return void
- */
- public function down()
- {
- //
- Schema::table('order', function (Blueprint $table) {
- $table->dropForeign(['customer_id', 'discount_id']);
- $table->drop();
- });
- }
-};
diff --git a/database/migrations/2024_01_31_155824_invoice.php b/database/migrations/2024_01_31_155824_invoice.php
deleted file mode 100644
index a20676e..0000000
--- a/database/migrations/2024_01_31_155824_invoice.php
+++ /dev/null
@@ -1,51 +0,0 @@
-id();
- $table->string('order_num', 255)->unique()->nullable();
- $table->integer('date')->nullable();
- $table->integer('due_date')->nullable();
- $table->integer('date_paid')->nullable();
- $table->integer('sub_total')->nullable();
- $table->integer('tax')->nullable();
- $table->integer('total')->nullable();
- $table->bigInteger('customer_id')->default(0)->index();
- $table->integer('order_id')->default(0)->index();
- $table->text('note')->nullable();
- $table->string('client_ip', 100)->nullable();
- $table->string('vm_id')->nullable()->index();
- $table->unsignedInteger('rebuild_count')->nullable()->default(0);
- $table->tinyInteger('invoice_type')->default(1);
- $table->integer('reminder_step')->default(3);
- $table->tinyInteger('status')->default(0);
-
- $table->timestamps();
- $table->softDeletes();
- });
- }
-
- /**
- * Reverse the migrations.
- *
- * @return void
- */
- public function down()
- {
- Schema::table('invoice', function (Blueprint $table) {
- $table->dropForeign(['payment_id', 'customer_id']);
- $table->drop();
- });
- }
-};
diff --git a/database/migrations/2024_01_31_164507_domain_type.php b/database/migrations/2024_01_31_164507_domain_type.php
deleted file mode 100644
index 12497e0..0000000
--- a/database/migrations/2024_01_31_164507_domain_type.php
+++ /dev/null
@@ -1,33 +0,0 @@
-id()->autoIncrement();
- $table->string('name',255)->nullable();
- $table->tinyInteger('status')->default(1);
- $table->timestamps();
- });
- }
-
- /**
- * Reverse the migrations.
- *
- * @return void
- */
- public function down()
- {
- Schema::dropIfExists('domain_type');
- }
-};
diff --git a/database/migrations/2024_01_31_165505_module_dns_server.php b/database/migrations/2024_01_31_165505_module_dns_server.php
index 8e557bd..aa6d59a 100644
--- a/database/migrations/2024_01_31_165505_module_dns_server.php
+++ b/database/migrations/2024_01_31_165505_module_dns_server.php
@@ -14,7 +14,7 @@ return new class extends Migration
public function up()
{
Schema::create('module_dns_server',function (Blueprint $table) {
- $table->id()->autoIncrement();
+ $table->id();
$table->string('name',100)->default(0);
$table->string('user',100)->nullable();
$table->text('password')->nullable();
diff --git a/database/migrations/2024_01_31_165959_module_hosting_server.php b/database/migrations/2024_01_31_165959_module_hosting_server.php
index abd925b..e8ba4b5 100644
--- a/database/migrations/2024_01_31_165959_module_hosting_server.php
+++ b/database/migrations/2024_01_31_165959_module_hosting_server.php
@@ -14,7 +14,7 @@ return new class extends Migration
public function up()
{
Schema::create('module_hosting_server',function (Blueprint $table) {
- $table->id()->autoIncrement();
+ $table->id();
$table->string('name',100)->nullable();
$table->string('user',100)->nullable();
$table->string('apikey',100)->nullable();
diff --git a/database/migrations/2024_02_01_080844_module_hosting.php b/database/migrations/2024_02_01_080844_module_hosting.php
index 6b23441..a992813 100644
--- a/database/migrations/2024_02_01_080844_module_hosting.php
+++ b/database/migrations/2024_02_01_080844_module_hosting.php
@@ -14,7 +14,7 @@ return new class extends Migration
public function up()
{
Schema::create('module_hosting',function (Blueprint $table) {
- $table->id()->autoIncrement();
+ $table->id();
$table->string('name',255)->nullable();
$table->bigInteger('order_id')->default(0)->index();
$table->integer('module_server_id')->default(0)->index();
diff --git a/database/migrations/2024_02_01_082942_v_center.php b/database/migrations/2024_02_01_082942_v_center.php
index 951b17e..69c0b7b 100644
--- a/database/migrations/2024_02_01_082942_v_center.php
+++ b/database/migrations/2024_02_01_082942_v_center.php
@@ -14,7 +14,7 @@ return new class extends Migration
public function up()
{
Schema::create('module_vcenter',function (Blueprint $table) {
- $table->id()->autoIncrement();
+ $table->id();
$table->string('host',100)->nullable();
$table->string('user',100)->nullable();
$table->text('password')->nullable();
diff --git a/database/migrations/2024_02_01_090134_module_datacenter.php b/database/migrations/2024_02_01_090134_module_datacenter.php
index efec5c6..33c6f9e 100644
--- a/database/migrations/2024_02_01_090134_module_datacenter.php
+++ b/database/migrations/2024_02_01_090134_module_datacenter.php
@@ -13,7 +13,7 @@ return new class extends Migration {
public function up()
{
Schema::create('module_datacenter', function (Blueprint $table) {
- $table->id()->autoIncrement();
+ $table->id();
$table->string('name', 255)->nullable();
$table->string('datacenter_id', 255)->nullable();
$table->foreignId('vcenter_id')->constrained('module_vcenter')->cascadeOnDelete()->nullable()->index();
diff --git a/database/migrations/2024_02_01_091327_module_folder.php b/database/migrations/2024_02_01_091327_module_folder.php
index 76c2cbc..7e711f3 100644
--- a/database/migrations/2024_02_01_091327_module_folder.php
+++ b/database/migrations/2024_02_01_091327_module_folder.php
@@ -14,7 +14,7 @@ return new class extends Migration
public function up()
{
Schema::create('module_folder',function (Blueprint $table) {
- $table->id()->autoIncrement();
+ $table->id();
$table->string('name',255)->nullable();
$table->string('folder',255)->nullable();
$table->string('type',100)->nullable();
diff --git a/database/migrations/2024_02_01_094827_module_ipadress.php b/database/migrations/2024_02_01_094827_module_ipadress.php
index a22d130..7aea0b8 100644
--- a/database/migrations/2024_02_01_094827_module_ipadress.php
+++ b/database/migrations/2024_02_01_094827_module_ipadress.php
@@ -13,20 +13,30 @@ return new class extends Migration
*/
public function up()
{
- Schema::create('module_ip_address',function (Blueprint $table) {
- $table->id()->autoIncrement();
- $table->string('vm_name',255)->nullable();
- $table->unsignedBigInteger('module_network_id')->nullable();
- $table->string('ip',100)->nullable();
- $table->string('gateway',100)->nullable();
- $table->string('netmask',100)->nullable();
- $table->string('dns',100)->nullable();
- $table->tinyInteger('status')->default(0)->comment('1: assign;0: free');
- $table->timestamps();
- $table->foreign('module_network_id', 'fk_module_ip_network')
- ->references('id')
- ->on('module_network')
+ Schema::create('module_ip_address', function (Blueprint $table) {
+ $table->id();
+ $table->string('vm_name')->nullable();
+
+ $table->foreignId('module_network_id')
+ ->nullable()
+ ->constrained('module_network')
->nullOnDelete();
+
+ $table->foreignId('module_host_id')
+ ->nullable()
+ ->constrained('module_host')
+ ->nullOnDelete();
+
+ $table->string('ip')->nullable();
+ $table->string('gateway')->nullable();
+ $table->string('netmask')->nullable();
+ $table->string('dns')->nullable();
+
+ $table->tinyInteger('status')
+ ->default(0)
+ ->comment('1: assign; 0: free');
+
+ $table->timestamps();
});
}
diff --git a/database/migrations/2024_02_01_100912_module_snapshot_limit.php b/database/migrations/2024_02_01_100912_module_snapshot_limit.php
index 26ba332..8c7d084 100644
--- a/database/migrations/2024_02_01_100912_module_snapshot_limit.php
+++ b/database/migrations/2024_02_01_100912_module_snapshot_limit.php
@@ -14,7 +14,7 @@ return new class extends Migration
public function up()
{
Schema::create('module_snapshot_limit',function (Blueprint $table) {
- $table->id()->autoIncrement();
+ $table->id();
$table->bigInteger('customer_id')->default(0)->index();
$table->integer('limit')->default(1);
$table->integer('used')->default(0);
diff --git a/database/migrations/2024_02_03_095630_module_vmware.php b/database/migrations/2024_02_03_095630_module_vmware.php
index 6cf8702..327c1d7 100644
--- a/database/migrations/2024_02_03_095630_module_vmware.php
+++ b/database/migrations/2024_02_03_095630_module_vmware.php
@@ -13,23 +13,36 @@ return new class extends Migration
*/
public function up()
{
- Schema::create('module_vmware',function (Blueprint $table) {
- $table->id()->autoIncrement();
- $table->string('name',255)->nullable();
- $table->bigInteger('module_ip_address_id')->default(0)->index();
+ Schema::create('module_vmware', function (Blueprint $table) {
+ $table->id();
+ $table->string('name')->nullable();
+
+ $table->foreignId('module_ip_address')
+ ->constrained('module_ip_address');
+
+ $table->foreignId('vcenter_id')
+ ->constrained('module_vcenter');
+
$table->integer('port')->nullable();
$table->integer('order_id')->nullable();
+
$table->integer('first_payment')->nullable();
$table->integer('next_payment')->nullable();
$table->integer('next_due_date')->nullable();
$table->integer('next_invoice')->nullable();
- $table->string('user',100)->nullable();
+
+ $table->string('user')->nullable();
$table->text('password')->nullable();
- $table->string('hostname',100)->nullable();
+ $table->string('hostname')->nullable();
$table->string('vmware_id')->nullable();
+
$table->bigInteger('os_id')->default(0)->index();
$table->bigInteger('customer_id')->default(0)->index();
- $table->tinyInteger('status')->default(1)->comment('1: Running;0:Off;2:Suspend');
+
+ $table->tinyInteger('status')
+ ->default(1)
+ ->comment('1: Running;0:Off;2:Suspend');
+
$table->timestamps();
});
}
diff --git a/database/migrations/2024_02_04_100435_module_snapshot.php b/database/migrations/2024_02_04_100435_module_snapshot.php
index 8c783c8..4cd4cf5 100644
--- a/database/migrations/2024_02_04_100435_module_snapshot.php
+++ b/database/migrations/2024_02_04_100435_module_snapshot.php
@@ -14,7 +14,7 @@ return new class extends Migration
public function up()
{
Schema::create('module_snapshot', function (Blueprint $table) {
- $table->id()->autoIncrement();
+ $table->id();
$table->string('name', 255)->nullable();
$table->bigInteger('customer_id')->default(0)->index();
$table->bigInteger('module_vmware_id')->default(0)->index();
diff --git a/database/migrations/2024_02_26_124405_order___order_item.php b/database/migrations/2024_02_26_124405_order___order_item.php
deleted file mode 100644
index 078050e..0000000
--- a/database/migrations/2024_02_26_124405_order___order_item.php
+++ /dev/null
@@ -1,42 +0,0 @@
-id();
- $table->unsignedBigInteger('order_id')->index();
- $table->unsignedBigInteger('product_id')->nullable()->index();
- $table->string('billing_cycle', 50)->nullable();
- $table->integer('months')->default(0);
- $table->unsignedBigInteger('os_id')->nullable()->index();
- $table->string('server_name')->nullable();
- $table->integer('quantity')->default(1);
- $table->integer('price')->default(0);
- $table->timestamps();
-
- // Foreign Keys
- $table->foreign('order_id')->references('id')->on('order')->onDelete('cascade');
- $table->foreign('product_id')->references('id')->on('products')->onDelete('set null');
- });
- }
-
- /**
- * Reverse the migrations.
- *
- * @return void
- */
- public function down()
- {
- Schema::dropIfExists('order__order_item');
- }
-};
diff --git a/database/migrations/2024_05_10_090436_staff_online.php b/database/migrations/2024_05_10_090436_staff_online.php
index 1209bf7..7eaae2b 100644
--- a/database/migrations/2024_05_10_090436_staff_online.php
+++ b/database/migrations/2024_05_10_090436_staff_online.php
@@ -15,7 +15,7 @@ return new class extends Migration
{
//
Schema::create('staff_online',function (Blueprint $table) {
- $table->id()->autoIncrement();
+ $table->id();
$table->string('sessions_id')->nullable();
$table->integer('user_id')->default(0)->index();
$table->integer('last_visited')->nullable();
diff --git a/database/migrations/2025_01_31_160440_invoice_item.php b/database/migrations/2025_01_31_160440_invoice_item.php
deleted file mode 100644
index 76d038e..0000000
--- a/database/migrations/2025_01_31_160440_invoice_item.php
+++ /dev/null
@@ -1,41 +0,0 @@
-id()->autoIncrement();
- $table->bigInteger('invoice_id')->default(0)->index();
- $table->unsignedBigInteger('product_id')->nullable()->index();
- $table->integer('months')->default(0);
- $table->unsignedBigInteger('os_id')->nullable()->index();
- $table->string('server_name')->nullable();
- $table->integer('quantity')->default(1);
- $table->tinyInteger('invoice_type')->default(1);
- $table->integer('price')->default(0);
- $table->string('product_type', 50)->nullable();
-
- $table->timestamps();
- });
- }
-
- /**
- * Reverse the migrations.
- *
- * @return void
- */
- public function down()
- {
- Schema::dropIfExists('invoice_item');
- }
-};
diff --git a/database/migrations/2025_05_16_215246_create_carts_table.php b/database/migrations/2025_05_16_215246_create_carts_table.php
deleted file mode 100644
index c51cf56..0000000
--- a/database/migrations/2025_05_16_215246_create_carts_table.php
+++ /dev/null
@@ -1,32 +0,0 @@
-id();
- $table->unsignedBigInteger('product_id');
- $table->unsignedBigInteger('customer_id');
- $table->unsignedTinyInteger('type_product');
- $table->integer('months')->default(0);
- $table->timestamps();
-
- $table->index(['customer_id', 'type_product', 'product_id']);
- });
- }
-
- /**
- * Reverse the migrations.
- */
- public function down(): void
- {
- Schema::dropIfExists('carts');
- }
-};
diff --git a/database/migrations/2025_05_20_161102_create_order_addons_table.php b/database/migrations/2025_05_20_161102_create_order_addons_table.php
deleted file mode 100644
index 3d298b2..0000000
--- a/database/migrations/2025_05_20_161102_create_order_addons_table.php
+++ /dev/null
@@ -1,34 +0,0 @@
-id();
- $table->unsignedBigInteger('order_item_id')->index();
- $table->unsignedBigInteger('addon_id')->index();
- $table->unsignedInteger('qty')->default(1);
- $table->unsignedInteger('price');
- $table->timestamps();
-
- // Foreign Keys
- $table->foreign('order_item_id')->references('id')->on('order__order_item')->onDelete('cascade');
- $table->foreign('addon_id')->references('id')->on('product_addon')->onDelete('cascade');
- });
- }
-
- /**
- * Reverse the migrations.
- */
- public function down(): void
- {
- Schema::dropIfExists('order__order_addons');
- }
-};
diff --git a/database/migrations/2025_05_21_154940_create_invoice_addons_table.php b/database/migrations/2025_05_21_154940_create_invoice_addons_table.php
deleted file mode 100644
index 16997cd..0000000
--- a/database/migrations/2025_05_21_154940_create_invoice_addons_table.php
+++ /dev/null
@@ -1,30 +0,0 @@
-id();
- $table->unsignedBigInteger('invoice_item_id')->index(); // FK đến order_items
- $table->unsignedBigInteger('addon_id');
- $table->unsignedInteger('qty')->default(1);
- $table->unsignedInteger('price'); // đơn giá tại thời điểm mua
- $table->timestamps();
- });
- }
-
- /**
- * Reverse the migrations.
- */
- public function down(): void
- {
- Schema::dropIfExists('invoice_addons');
- }
-};
diff --git a/database/migrations/2025_07_14_162409_create_product_vps_os_table.php b/database/migrations/2025_07_14_162409_create_product_vps_os_table.php
deleted file mode 100644
index ac15bba..0000000
--- a/database/migrations/2025_07_14_162409_create_product_vps_os_table.php
+++ /dev/null
@@ -1,33 +0,0 @@
-id();
- $table->unsignedBigInteger('product_id')->index();
- $table->integer('product_type')->nullable();
- $table->unsignedBigInteger('os_id')->index();
-
- $table->foreign('product_id')->references('id')->on('products')->onDelete('cascade');
- $table->foreign('os_id')->references('id')->on('os')->onDelete('cascade');
- $table->timestamps();
- });
- }
-
- /**
- * Reverse the migrations.
- */
- public function down(): void
- {
- Schema::dropIfExists('product_os');
- }
-};
diff --git a/database/migrations/2025_07_15_084129_add_customer_id_index_to_carts_table.php b/database/migrations/2025_07_15_084129_add_customer_id_index_to_carts_table.php
deleted file mode 100644
index 38c4d48..0000000
--- a/database/migrations/2025_07_15_084129_add_customer_id_index_to_carts_table.php
+++ /dev/null
@@ -1,29 +0,0 @@
-index('customer_id', 'carts_customer_id_index');
- });
- }
-
- /**
- * Reverse the migrations.
- */
- public function down(): void
- {
- Schema::table('carts', function (Blueprint $table) {
- $table->dropIndex('carts_customer_id_index');
- });
- }
-};
diff --git a/database/migrations/2025_07_15_151140_create_edit_column_invoice_table.php b/database/migrations/2025_07_15_151140_create_edit_column_invoice_table.php
deleted file mode 100644
index b008ecb..0000000
--- a/database/migrations/2025_07_15_151140_create_edit_column_invoice_table.php
+++ /dev/null
@@ -1,62 +0,0 @@
-date('date_tmp')->nullable()->after('date');
- $table->date('due_date_tmp')->nullable()->after('due_date');
- $table->date('date_paid_tmp')->nullable()->after('date_paid');
- });
-
- // 2. Chuyển giá trị từ INT → DATE
- DB::statement("UPDATE invoice SET date_tmp = FROM_UNIXTIME(date) WHERE date IS NOT NULL");
- DB::statement("UPDATE invoice SET due_date_tmp = FROM_UNIXTIME(due_date) WHERE due_date IS NOT NULL");
- DB::statement("UPDATE invoice SET date_paid_tmp = FROM_UNIXTIME(date_paid) WHERE date_paid IS NOT NULL");
-
- // 3. Xoá cột cũ
- Schema::table('invoice', function (Blueprint $table) {
- $table->dropColumn(['date', 'due_date', 'date_paid']);
- });
-
- // 4. Đổi tên cột tạm thành tên cũ
- Schema::table('invoice', function (Blueprint $table) {
- $table->renameColumn('date_tmp', 'date');
- $table->renameColumn('due_date_tmp', 'due_date');
- $table->renameColumn('date_paid_tmp', 'date_paid');
- });
- }
-
- public function down(): void
- {
- // 1. Thêm 3 cột tạm kiểu INT
- Schema::table('invoice', function (Blueprint $table) {
- $table->integer('date_int')->nullable();
- $table->integer('due_date_int')->nullable();
- $table->integer('date_paid_int')->nullable();
- });
-
- // 2. Convert DATE → Unix timestamp
- DB::statement("UPDATE invoice SET date_int = UNIX_TIMESTAMP(date) WHERE date IS NOT NULL");
- DB::statement("UPDATE invoice SET due_date_int = UNIX_TIMESTAMP(due_date) WHERE due_date IS NOT NULL");
- DB::statement("UPDATE invoice SET date_paid_int = UNIX_TIMESTAMP(date_paid) WHERE date_paid IS NOT NULL");
-
- // 3. Xoá cột kiểu DATE
- Schema::table('invoice', function (Blueprint $table) {
- $table->dropColumn(['date', 'due_date', 'date_paid']);
- });
-
- // 4. Đổi tên lại
- Schema::table('invoice', function (Blueprint $table) {
- $table->renameColumn('date_int', 'date');
- $table->renameColumn('due_date_int', 'due_date');
- $table->renameColumn('date_paid_int', 'date_paid');
- });
- }
-};
diff --git a/database/migrations/2026_01_27_151851_update_forigin_key_module_ip_table.php b/database/migrations/2026_01_27_151851_update_forigin_key_module_ip_table.php
deleted file mode 100644
index db716cd..0000000
--- a/database/migrations/2026_01_27_151851_update_forigin_key_module_ip_table.php
+++ /dev/null
@@ -1,30 +0,0 @@
-unsignedBigInteger('module_network_id')->nullable()->default(null)->change();
- $table->foreign('module_network_id')->references('id')->on('module_network')->onDelete('set null');
- });
- }
-
- /**
- * Reverse the migrations.
- */
- public function down(): void
- {
- Schema::table('module_ip_address', function (Blueprint $table) {
- $table->dropForeign(['module_network_id']);
- $table->bigInteger('module_network_id')->default(0)->nullable(false)->change();
- });
- }
-};
diff --git a/database/migrations/2026_01_27_172000_add_foreign_keys_to_order_table.php b/database/migrations/2026_01_27_172000_add_foreign_keys_to_order_table.php
deleted file mode 100644
index 850c57a..0000000
--- a/database/migrations/2026_01_27_172000_add_foreign_keys_to_order_table.php
+++ /dev/null
@@ -1,64 +0,0 @@
-unsignedBigInteger('customer_id')->nullable()->default(null)->change();
- $table->unsignedBigInteger('user_id')->nullable()->default(null)->change();
- $table->unsignedBigInteger('discount_id')->nullable()->default(null)->change();
- $table->unsignedBigInteger('invoice_id')->nullable()->default(null)->change();
- });
-
- // 2. Convert default 0 values to null to ensure foreign key integrity
- DB::table('order')->where('customer_id', 0)->update(['customer_id' => null]);
- DB::table('order')->where('user_id', 0)->update(['user_id' => null]);
- DB::table('order')->where('discount_id', 0)->update(['discount_id' => null]);
- DB::table('order')->where('invoice_id', 0)->update(['invoice_id' => null]);
-
- // 3. Add foreign key constraints
- Schema::table('order', function (Blueprint $table) {
- $table->foreign('customer_id')->references('id')->on('customers')->onDelete('set null');
- $table->foreign('user_id')->references('id')->on('user')->onDelete('set null');
- $table->foreign('discount_id')->references('id')->on('discount')->onDelete('set null');
- $table->foreign('invoice_id')->references('id')->on('invoice')->onDelete('set null');
- });
- }
-
- /**
- * Reverse the migrations.
- */
- public function down(): void
- {
- Schema::table('order', function (Blueprint $table) {
- // Drop foreign keys
- $table->dropForeign(['customer_id']);
- $table->dropForeign(['user_id']);
- $table->dropForeign(['discount_id']);
- $table->dropForeign(['invoice_id']);
-
- // Revert columns to previous state (integers/bigInteger with default 0)
- // Note: This matches the original migration definitions
- $table->bigInteger('customer_id')->default(0)->nullable(false)->change();
- $table->integer('user_id')->default(0)->nullable(false)->change();
- $table->integer('discount_id')->default(0)->nullable(false)->change();
- $table->integer('invoice_id')->default(0)->nullable(false)->change();
- });
-
- // Restore 0 values where null (optional, but keeps consistency with down())
- DB::table('order')->whereNull('customer_id')->update(['customer_id' => 0]);
- DB::table('order')->whereNull('user_id')->update(['user_id' => 0]);
- DB::table('order')->whereNull('discount_id')->update(['discount_id' => 0]);
- DB::table('order')->whereNull('invoice_id')->update(['invoice_id' => 0]);
- }
-};
diff --git a/database/migrations/2026_01_29_120313_create_virtual_machines_table.php b/database/migrations/2026_01_29_120313_create_virtual_machines_table.php
index 880c034..cc18fdf 100644
--- a/database/migrations/2026_01_29_120313_create_virtual_machines_table.php
+++ b/database/migrations/2026_01_29_120313_create_virtual_machines_table.php
@@ -26,6 +26,9 @@ return new class extends Migration {
$table->boolean('is_system')->default(false); // vCLS, system VM
$table->timestamp('last_synced_at')->nullable();
$table->foreignId('datacenter_id')->constrained('module_datacenter')->nullable();
+ $table->foreignId('host_id')->nullable()->constrained('module_host');
+
+
$table->timestamps();
});
}
diff --git a/database/migrations/2026_01_30_170136_update_module_datacenter_id_in_module_host_table.php b/database/migrations/2026_01_30_170136_update_module_datacenter_id_in_module_host_table.php
deleted file mode 100644
index 4d83c15..0000000
--- a/database/migrations/2026_01_30_170136_update_module_datacenter_id_in_module_host_table.php
+++ /dev/null
@@ -1,37 +0,0 @@
-unsignedBigInteger('module_datacenter_id')->nullable()->change();
-
- $table->foreign('module_datacenter_id')
- ->references('id')
- ->on('module_datacenter')
- ->cascadeOnDelete();
- });
- }
-
- /**
- * Reverse the migrations.
- */
- public function down(): void
- {
- Schema::table('module_host', function (Blueprint $table) {
- // Drop the foreign key first
- $table->dropForeign(['module_datacenter_id']);
-
- // Revert column type (assuming it was bigInteger with default 0 based on previous context)
- $table->bigInteger('module_datacenter_id')->default(0)->change();
- });
- }
-};
diff --git a/database/migrations/2026_01_30_170804_update_module_vcenter_id_in_module_datacenter.php b/database/migrations/2026_01_30_170804_update_module_vcenter_id_in_module_datacenter.php
deleted file mode 100644
index 02618c0..0000000
--- a/database/migrations/2026_01_30_170804_update_module_vcenter_id_in_module_datacenter.php
+++ /dev/null
@@ -1,33 +0,0 @@
-unsignedBigInteger('vcenter_id')->nullable()->change();
-
- $table->foreign('vcenter_id')
- ->references('id')
- ->on('module_vcenter')
- ->cascadeOnDelete();
- });
- }
-
- /**
- * Reverse the migrations.
- */
- public function down(): void
- {
- Schema::table('module_datacenter', function (Blueprint $table) {
- //
- });
- }
-};
diff --git a/database/migrations/2026_01_31_093643_update_fk_column_module_folder.php b/database/migrations/2026_01_31_093643_update_fk_column_module_folder.php
deleted file mode 100644
index e9f9aee..0000000
--- a/database/migrations/2026_01_31_093643_update_fk_column_module_folder.php
+++ /dev/null
@@ -1,48 +0,0 @@
-unsignedBigInteger('module_datacenter_id')->nullable()->change();
- });
-
- // Clean up orphan records that point to non-existent datacenters
- DB::table('module_folder')
- ->whereNotNull('module_datacenter_id')
- ->whereNotExists(function ($query) {
- $query->select(DB::raw(1))
- ->from('module_datacenter')
- ->whereColumn('module_folder.module_datacenter_id', 'module_datacenter.id');
- })
- ->update(['module_datacenter_id' => null]);
-
- Schema::table('module_folder', function (Blueprint $table) {
- $table->foreign('module_datacenter_id')
- ->references('id')
- ->on('module_datacenter')
- ->cascadeOnDelete();
- });
- }
-
- /**
- * Reverse the migrations.
- */
- public function down(): void
- {
- Schema::table('module_folder', function (Blueprint $table) {
- $table->dropForeign(['module_datacenter_id']);
- // Revert changes if necessary, though restoring invalid data isn't possible.
- // We just restore the foreign key constraint if that was the intent.
- $table->foreign('module_datacenter_id')->references('id')->on('module_datacenter')->onDelete('cascade');
- });
- }
-};
diff --git a/database/migrations/2026_02_08_210451_add_product_support_to_carts_and_order_items_tables.php b/database/migrations/2026_02_08_210451_add_product_support_to_carts_and_order_items_tables.php
deleted file mode 100644
index e35ddfa..0000000
--- a/database/migrations/2026_02_08_210451_add_product_support_to_carts_and_order_items_tables.php
+++ /dev/null
@@ -1,60 +0,0 @@
-string('billing_cycle', 50)->nullable()->after('months')->comment('Billing cycle: monthly, semi_annually, annually, etc.');
- }
- if (!Schema::hasColumn('carts', 'product_pricing_id')) {
- $table->unsignedBigInteger('product_pricing_id')->nullable()->after('billing_cycle')->comment('Link to product_pricings table');
- $table->index('product_pricing_id');
- }
-
- // Make type_product nullable for new Product system
- $table->integer('type_product')->nullable()->change();
- });
-
- // Add support for new Product system to order__order_item table
- Schema::table('order__order_item', function (Blueprint $table) {
- if (!Schema::hasColumn('order__order_item', 'billing_cycle')) {
- $table->string('billing_cycle', 50)->nullable()->after('months')->comment('Billing cycle: monthly, semi_annually, annually, etc.');
- }
- if (!Schema::hasColumn('order__order_item', 'product_pricing_id')) {
- $table->unsignedBigInteger('product_pricing_id')->nullable()->after('billing_cycle')->comment('Link to product_pricings table');
- $table->index('product_pricing_id');
- }
-
- // Make product_type nullable for new Product system
- if (Schema::hasColumn('order__order_item', 'product_type')) {
- $table->string('product_type', 50)->nullable()->change();
- }
- });
- }
-
- /**
- * Reverse the migrations.
- */
- public function down(): void
- {
- Schema::table('carts', function (Blueprint $table) {
- $table->dropColumn(['billing_cycle', 'product_pricing_id']);
- // $table->dropIndex(['product_pricing_id']);
- });
-
- Schema::table('order__order_item', function (Blueprint $table) {
- $table->dropColumn(['billing_cycle', 'product_pricing_id']);
- // $table->dropIndex(['product_pricing_id']);
- });
- }
-};
diff --git a/database/migrations/2026_02_15_121859_add_host_id_to_virtual_machines_table.php b/database/migrations/2026_02_15_121859_add_host_id_to_virtual_machines_table.php
deleted file mode 100644
index 4db202e..0000000
--- a/database/migrations/2026_02_15_121859_add_host_id_to_virtual_machines_table.php
+++ /dev/null
@@ -1,29 +0,0 @@
-foreignId('host_id')->nullable()->after('datacenter_id')->constrained('module_host');
- });
- }
-
- /**
- * Reverse the migrations.
- */
- public function down(): void
- {
- Schema::table('virtual_machines', function (Blueprint $table) {
- $table->dropForeign(['host_id']);
- $table->dropColumn('host_id');
- });
- }
-};
diff --git a/database/migrations/2026_02_16_000000_add_softdeletes_to_customer_invoice_order_tables.php b/database/migrations/2026_02_16_000000_add_softdeletes_to_customer_invoice_order_tables.php
deleted file mode 100644
index e8c1907..0000000
--- a/database/migrations/2026_02_16_000000_add_softdeletes_to_customer_invoice_order_tables.php
+++ /dev/null
@@ -1,45 +0,0 @@
-softDeletes();
- // });
-
- // Schema::table('invoice', function (Blueprint $table) {
- // $table->softDeletes();
- // });
-
- // Schema::table('order', function (Blueprint $table) {
- // $table->softDeletes();
- // });
-
- // Schema::table('transaction', function (Blueprint $table) {
- // $table->softDeletes();
- // });
- }
-
- public function down()
- {
- Schema::table('customers', function (Blueprint $table) {
- $table->dropSoftDeletes();
- });
-
- Schema::table('invoice', function (Blueprint $table) {
- $table->dropSoftDeletes();
- });
-
- Schema::table('order', function (Blueprint $table) {
- $table->dropSoftDeletes();
- });
-
- Schema::table('transaction', function (Blueprint $table) {
- $table->dropSoftDeletes();
- });
- }
-};
diff --git a/database/migrations/2026_02_16_120000_add_softdeletes_to_transaction_table.php b/database/migrations/2026_02_16_120000_add_softdeletes_to_transaction_table.php
deleted file mode 100644
index 6292c99..0000000
--- a/database/migrations/2026_02_16_120000_add_softdeletes_to_transaction_table.php
+++ /dev/null
@@ -1,21 +0,0 @@
-softDeletes();
- // });
- }
-
- public function down()
- {
- Schema::table('transaction', function (Blueprint $table) {
- $table->dropSoftDeletes();
- });
- }
-};
diff --git a/database/migrations/2026_03_01_184412_drop_product_type_from_product_os_table.php b/database/migrations/2026_03_01_184412_drop_product_type_from_product_os_table.php
deleted file mode 100644
index 2f77e6d..0000000
--- a/database/migrations/2026_03_01_184412_drop_product_type_from_product_os_table.php
+++ /dev/null
@@ -1,28 +0,0 @@
-dropColumn('product_type');
- });
- }
-
- /**
- * Reverse the migrations.
- */
- public function down(): void
- {
- Schema::table('product_os', function (Blueprint $table) {
- //
- });
- }
-};
diff --git a/database/migrations/2026_03_01_223038_add_vcenter_id_to_module_vmware_table.php b/database/migrations/2026_03_01_223038_add_vcenter_id_to_module_vmware_table.php
deleted file mode 100644
index 00c3889..0000000
--- a/database/migrations/2026_03_01_223038_add_vcenter_id_to_module_vmware_table.php
+++ /dev/null
@@ -1,27 +0,0 @@
-unsignedBigInteger('vcenter_id')->nullable()->after('module_ip_address_id');
- $table->foreign('vcenter_id')->references('id')->on('module_vcenter')->nullOnDelete();
- });
- }
-
- public function down(): void
- {
- Schema::table('module_vmware', function (Blueprint $table) {
- $table->dropForeign(['vcenter_id']);
- $table->dropColumn('vcenter_id');
- });
- }
-};
diff --git a/database/migrations/2026_03_02_100000_add_auto_renew_to_invoice_table.php b/database/migrations/2026_03_02_100000_add_auto_renew_to_invoice_table.php
deleted file mode 100644
index 1e24639..0000000
--- a/database/migrations/2026_03_02_100000_add_auto_renew_to_invoice_table.php
+++ /dev/null
@@ -1,25 +0,0 @@
-boolean('auto_renew')->default(false)->after('status');
- });
- }
- }
-
- public function down(): void
- {
- if (Schema::hasColumn('invoice', 'auto_renew')) {
- Schema::table('invoice', function (Blueprint $table) {
- $table->dropColumn('auto_renew');
- });
- }
- }
-};
diff --git a/database/migrations/2026_03_02_111537_update_column_invoice_table.php b/database/migrations/2026_03_02_111537_update_column_invoice_table.php
deleted file mode 100644
index 2673222..0000000
--- a/database/migrations/2026_03_02_111537_update_column_invoice_table.php
+++ /dev/null
@@ -1,28 +0,0 @@
-unsignedBigInteger('module_host_id')->nullable()->after('module_network_id');
- $table->foreign('module_host_id', 'fk_module_ip_host')
- ->references('id')
- ->on('module_host')
- ->nullOnDelete();
- });
- }
-
- /**
- * Reverse the migrations.
- */
- public function down(): void
- {
- Schema::table('module_ip_address', function (Blueprint $table) {
- //
- });
- }
-};
diff --git a/database/migrations/2026_03_10_000001_add_birthday_address_phone_to_user_table.php b/database/migrations/2026_03_10_000001_add_birthday_address_phone_to_user_table.php
deleted file mode 100644
index 54327ef..0000000
--- a/database/migrations/2026_03_10_000001_add_birthday_address_phone_to_user_table.php
+++ /dev/null
@@ -1,24 +0,0 @@
-date('birthday')->nullable()->after('email');
- $table->string('address')->nullable()->after('birthday');
- $table->string('phone', 20)->nullable()->after('address');
- });
- }
-
- public function down(): void
- {
- Schema::table('user', function (Blueprint $table) {
- $table->dropColumn(['birthday', 'address', 'phone']);
- });
- }
-};
diff --git a/database/migrations/2026_03_11_000001_add_payment_method_to_invoice_and_order_tables.php b/database/migrations/2026_03_11_000001_add_payment_method_to_invoice_and_order_tables.php
deleted file mode 100644
index af05f41..0000000
--- a/database/migrations/2026_03_11_000001_add_payment_method_to_invoice_and_order_tables.php
+++ /dev/null
@@ -1,31 +0,0 @@
-string('payment_method')->nullable()->after('status');
- });
-
- Schema::table('order', function (Blueprint $table) {
- if (!Schema::hasColumn('order', 'payment_method')) {
- $table->string('payment_method')->nullable()->after('status');
- }
- });
- }
-
- public function down(): void
- {
- Schema::table('invoice', function (Blueprint $table) {
- $table->dropColumn('payment_method');
- });
-
- Schema::table('order', function (Blueprint $table) {
- $table->dropColumn('payment_method');
- });
- }
-};
diff --git a/database/migrations/2026_03_11_000002_add_payment_proof_to_order_table.php b/database/migrations/2026_03_11_000002_add_payment_proof_to_order_table.php
deleted file mode 100644
index 2d79eef..0000000
--- a/database/migrations/2026_03_11_000002_add_payment_proof_to_order_table.php
+++ /dev/null
@@ -1,21 +0,0 @@
-string('payment_proof')->nullable()->after('payment_method');
- });
- }
-
- public function down(): void
- {
- Schema::table('order', function (Blueprint $table) {
- $table->dropColumn('payment_proof');
- });
- }
-};
diff --git a/database/migrations/2026_03_11_144540_rename_order_items_table.php b/database/migrations/2026_03_11_144540_rename_order_items_table.php
deleted file mode 100644
index de2224c..0000000
--- a/database/migrations/2026_03_11_144540_rename_order_items_table.php
+++ /dev/null
@@ -1,24 +0,0 @@
-timestamp('expiry_date')->nullable()->after('months');
- });
- }
-
- /**
- * Reverse the migrations.
- */
- public function down(): void
- {
- Schema::table('order_items', function (Blueprint $table) {
- $table->dropColumn('expiry_date');
- });
- }
-};
diff --git a/database/migrations/2026_03_11_200001_create_orders_table.php b/database/migrations/2026_03_11_200001_create_orders_table.php
new file mode 100644
index 0000000..0b9945d
--- /dev/null
+++ b/database/migrations/2026_03_11_200001_create_orders_table.php
@@ -0,0 +1,46 @@
+id();
+
+ $table->string('order_num', 50)->unique()->comment('Human-readable order number e.g. ORD-20260311-00001');
+
+ $table->unsignedBigInteger('customer_id')->nullable()->index();
+ $table->unsignedBigInteger('user_id')->nullable()->index()->comment('Staff who created or processed the order');
+ $table->unsignedBigInteger('discount_id')->nullable()->index();
+
+ $table->unsignedBigInteger('sub_total')->default(0)->comment('Total before discount, in VND');
+ $table->unsignedBigInteger('discount_amount')->default(0);
+ $table->unsignedBigInteger('total')->default(0)->comment('Final amount due, in VND');
+
+ // 0=pending, 1=completed, 2=cancelled, 3=processing
+ $table->tinyInteger('status')->default(0)->index();
+
+ $table->string('payment_method', 100)->nullable();
+ $table->string('payment_proof')->nullable()->comment('Path to uploaded bank-transfer proof image');
+
+ $table->text('note')->nullable();
+ $table->string('client_ip', 45)->nullable();
+
+ $table->timestamps();
+ $table->softDeletes();
+
+ $table->foreign('customer_id')->references('id')->on('customers')->onDelete('set null');
+ $table->foreign('user_id')->references('id')->on('user')->onDelete('set null');
+ $table->foreign('discount_id')->references('id')->on('discount')->onDelete('set null');
+ });
+ }
+
+ public function down(): void
+ {
+ Schema::dropIfExists('orders');
+ }
+};
diff --git a/database/migrations/2026_03_11_200002_create_order_items_table.php b/database/migrations/2026_03_11_200002_create_order_items_table.php
new file mode 100644
index 0000000..f1110ad
--- /dev/null
+++ b/database/migrations/2026_03_11_200002_create_order_items_table.php
@@ -0,0 +1,46 @@
+id();
+
+ $table->unsignedBigInteger('order_id')->index();
+ $table->unsignedBigInteger('product_id')->nullable()->index();
+ $table->unsignedBigInteger('product_pricing_id')->nullable()->index()
+ ->comment('Snapshot of product_pricings row at purchase time');
+
+ $table->unsignedBigInteger('os_id')->nullable()->index()
+ ->comment('Selected OS for VPS orders');
+ $table->string('server_name', 255)->nullable()
+ ->comment('Desired hostname entered by customer');
+
+ $table->string('billing_cycle', 30)->nullable()
+ ->comment('monthly | quarterly | semi_annually | annually');
+ $table->unsignedSmallInteger('months')->default(0);
+
+ $table->unsignedInteger('quantity')->default(1);
+ $table->unsignedBigInteger('price')->default(0)
+ ->comment('Unit price at time of order, in VND');
+
+ $table->json('addons_meta')->nullable()
+ ->comment('Stores addon selections: [{addon_id, qty, price},...] — preserved until service provisioning');
+
+ $table->timestamps();
+
+ $table->foreign('order_id')->references('id')->on('orders')->onDelete('cascade');
+ $table->foreign('product_id')->references('id')->on('products')->onDelete('set null');
+ });
+ }
+
+ public function down(): void
+ {
+ Schema::dropIfExists('order_items');
+ }
+};
diff --git a/database/migrations/2026_03_11_200003_create_invoices_table.php b/database/migrations/2026_03_11_200003_create_invoices_table.php
new file mode 100644
index 0000000..ca99739
--- /dev/null
+++ b/database/migrations/2026_03_11_200003_create_invoices_table.php
@@ -0,0 +1,68 @@
+id();
+
+ $table->string('invoice_number', 50)->unique()
+ ->comment('Human-readable e.g. INV-20260311-00001');
+
+ $table->unsignedBigInteger('customer_id')->index();
+ $table->unsignedBigInteger('order_id')->nullable()->index()
+ ->comment('Originating order; null for manual/renewal invoices');
+
+ /**
+ * invoice_type:
+ * 1 = new service
+ * 2 = renewal
+ * 3 = upgrade / addon purchase
+ */
+ $table->tinyInteger('invoice_type')->default(1)->index();
+
+ $table->unsignedBigInteger('sub_total')->default(0);
+ $table->unsignedBigInteger('discount_amount')->default(0);
+ $table->unsignedBigInteger('tax')->default(0);
+ $table->unsignedBigInteger('total')->default(0)->comment('Amount the customer must pay, in VND');
+
+ /**
+ * status:
+ * 1 = unpaid
+ * 2 = paid
+ * 3 = cancelled
+ * 4 = overdue
+ * 5 = refunded
+ */
+ $table->tinyInteger('status')->default(1)->index();
+
+ $table->string('payment_method', 100)->nullable();
+ $table->boolean('auto_renew')->default(false);
+
+ $table->date('due_date')->nullable();
+ $table->timestamp('paid_at')->nullable();
+
+ $table->text('note')->nullable();
+ $table->string('client_ip', 45)->nullable();
+
+ $table->unsignedInteger('reminder_step')->default(0)
+ ->comment('How many overdue reminders have been sent');
+
+ $table->timestamps();
+ $table->softDeletes();
+
+ $table->foreign('customer_id')->references('id')->on('customers')->onDelete('cascade');
+ $table->foreign('order_id')->references('id')->on('orders')->onDelete('set null');
+ });
+ }
+
+ public function down(): void
+ {
+ Schema::dropIfExists('invoices');
+ }
+};
diff --git a/database/migrations/2026_03_11_200004_create_invoice_items_table.php b/database/migrations/2026_03_11_200004_create_invoice_items_table.php
new file mode 100644
index 0000000..4641404
--- /dev/null
+++ b/database/migrations/2026_03_11_200004_create_invoice_items_table.php
@@ -0,0 +1,46 @@
+id();
+
+ $table->unsignedBigInteger('invoice_id')->index();
+ $table->unsignedBigInteger('product_id')->nullable()->index();
+
+ /**
+ * type:
+ * service — new or renewed service line
+ * addon — additional resource (extra IP, backup, etc.)
+ * upgrade — resource upgrade on existing service
+ */
+ $table->string('type', 20)->default('service')->index()
+ ->comment('service | addon | upgrade');
+
+ $table->string('description', 500)->nullable()
+ ->comment('Human-readable line item label shown on invoice PDF');
+
+ $table->unsignedSmallInteger('months')->default(0);
+ $table->unsignedInteger('quantity')->default(1);
+
+ $table->unsignedBigInteger('unit_price')->default(0)->comment('Per-unit price, in VND');
+ $table->unsignedBigInteger('total_price')->default(0)->comment('unit_price × quantity');
+
+ $table->timestamps();
+
+ $table->foreign('invoice_id')->references('id')->on('invoices')->onDelete('cascade');
+ $table->foreign('product_id')->references('id')->on('products')->onDelete('set null');
+ });
+ }
+
+ public function down(): void
+ {
+ Schema::dropIfExists('invoice_items');
+ }
+};
diff --git a/database/migrations/2026_03_11_200005_create_services_table.php b/database/migrations/2026_03_11_200005_create_services_table.php
new file mode 100644
index 0000000..dd3f8b0
--- /dev/null
+++ b/database/migrations/2026_03_11_200005_create_services_table.php
@@ -0,0 +1,61 @@
+id();
+
+ $table->unsignedBigInteger('customer_id')->index();
+ $table->unsignedBigInteger('product_id')->nullable()->index();
+
+ $table->unsignedBigInteger('order_item_id')->nullable()->index()
+ ->comment('The specific order line that provisioned this service');
+ $table->unsignedBigInteger('invoice_id')->nullable()->index()
+ ->comment('Most recent invoice that covers this service (renewal updates this)');
+
+ $table->string('vm_id')->nullable()->index()
+ ->comment('VMware vCenter VM identifier (VPS only)');
+ $table->string('server_name', 255)->nullable()
+ ->comment('Assigned hostname / domain');
+
+ /**
+ * status lifecycle:
+ * pending → awaiting provisioning after payment
+ * active → running normally
+ * suspended → overdue / policy violation
+ * expired → past expires_at, grace period ended
+ * cancelled → customer cancelled
+ * terminated → data wiped, permanent
+ */
+ $table->string('status', 20)->default('pending')->index();
+
+ $table->boolean('auto_renew')->default(false);
+
+ $table->timestamp('started_at')->nullable();
+ $table->timestamp('expires_at')->nullable()->index();
+ $table->timestamp('suspended_at')->nullable();
+ $table->timestamp('cancelled_at')->nullable();
+
+ $table->text('note')->nullable();
+
+ $table->timestamps();
+ $table->softDeletes();
+
+ $table->foreign('customer_id')->references('id')->on('customers')->onDelete('cascade');
+ $table->foreign('product_id')->references('id')->on('products')->onDelete('set null');
+ $table->foreign('order_item_id')->references('id')->on('order_items')->onDelete('set null');
+ $table->foreign('invoice_id')->references('id')->on('invoices')->onDelete('set null');
+ });
+ }
+
+ public function down(): void
+ {
+ Schema::dropIfExists('services');
+ }
+};
diff --git a/database/migrations/2026_03_11_200006_create_service_addons_table.php b/database/migrations/2026_03_11_200006_create_service_addons_table.php
new file mode 100644
index 0000000..2d2e918
--- /dev/null
+++ b/database/migrations/2026_03_11_200006_create_service_addons_table.php
@@ -0,0 +1,45 @@
+id();
+
+ $table->unsignedBigInteger('service_id')->index();
+
+ /**
+ * VPS addon_types : cpu | ram | disk | bandwidth | ip
+ * Hosting addon_types: panel | backup | ssl | email_account
+ *
+ * addon_value stores the raw value as a string:
+ * cpu → "4" (vCPU count)
+ * ram → "8192" (MB)
+ * disk → "100" (GB)
+ * panel → "cpanel" | "directadmin" | "plesk"
+ * ssl → "1" (boolean flag)
+ */
+ $table->string('addon_type', 50)->index()
+ ->comment('cpu | ram | disk | bandwidth | ip | panel | backup | ssl');
+ $table->string('addon_value', 255)
+ ->comment('Raw value: "4" for cpu, "8192" for ram MB, "cpanel" for panel');
+
+ $table->unsignedBigInteger('price')->default(0)
+ ->comment('Monthly price of this addon at time of provisioning, in VND');
+
+ $table->timestamps();
+
+ $table->foreign('service_id')->references('id')->on('services')->onDelete('cascade');
+ });
+ }
+
+ public function down(): void
+ {
+ Schema::dropIfExists('service_addons');
+ }
+};
diff --git a/database/migrations/2026_03_10_000001_create_invoice_attachments_table.php b/database/migrations/2026_03_11_200007_create_invoice_attachments_table.php
similarity index 59%
rename from database/migrations/2026_03_10_000001_create_invoice_attachments_table.php
rename to database/migrations/2026_03_11_200007_create_invoice_attachments_table.php
index ca4aba0..ebaba4c 100644
--- a/database/migrations/2026_03_10_000001_create_invoice_attachments_table.php
+++ b/database/migrations/2026_03_11_200007_create_invoice_attachments_table.php
@@ -10,16 +10,18 @@ return new class extends Migration
{
Schema::create('invoice_attachments', function (Blueprint $table) {
$table->id();
+
$table->unsignedBigInteger('invoice_id')->index();
- $table->string('filename'); // tên file gốc
- $table->string('path'); // đường dẫn lưu trữ (storage)
- $table->string('disk')->default('public');
+ $table->string('filename')->comment('Original uploaded filename');
+ $table->string('path')->comment('Storage path relative to disk root');
+ $table->string('disk', 50)->default('public');
$table->string('mime_type', 100)->nullable();
- $table->unsignedBigInteger('size')->default(0); // bytes
- $table->string('uploaded_by')->nullable(); // staff username/id
+ $table->unsignedBigInteger('size')->default(0)->comment('File size in bytes');
+ $table->string('uploaded_by', 100)->nullable()->comment('Staff username or ID');
+
$table->timestamps();
- $table->foreign('invoice_id')->references('id')->on('invoice')->onDelete('cascade');
+ $table->foreign('invoice_id')->references('id')->on('invoices')->onDelete('cascade');
});
}
diff --git a/database/seeders/DatabaseSeeder.php b/database/seeders/DatabaseSeeder.php
index 3c812f0..737e76d 100644
--- a/database/seeders/DatabaseSeeder.php
+++ b/database/seeders/DatabaseSeeder.php
@@ -15,20 +15,14 @@ class DatabaseSeeder extends Seeder
*/
public function run(): void
{
- $this->call(DomainTypeSeeder::class);
- $this->call(GroupSeeder::class);
- $this->call(Payment::class);
- $this->call(AddonSeeder::class);
+ $this->call(BankTransferPaymentSeeder::class);
$this->call(RoleSeeder::class);
$this->call(RolesAndPermissions::class);
$user = User::find(1);
$user->assignRole('Full Administrator');
- $this->call(SnapshotLimitedSeeder::class);
$this->call(TemplateSeeder::class);
$this->call(Setting::class);
- $this->call(MenuSeeder::class);
$this->call(ControlPanelSeeder::class);
- $this->call(VcenterSeeder::class);
$this->call(VPSProductSeeder::class);
}
}
diff --git a/public/.htaccess b/public/.htaccess
index 3aec5e2..90c252d 100644
--- a/public/.htaccess
+++ b/public/.htaccess
@@ -5,6 +5,46 @@
RewriteEngine On
+ # Enable Gzip Compression
+
+ # Compress HTML, CSS, JavaScript, Text, XML and fonts
+ AddOutputFilterByType DEFLATE application/javascript
+ AddOutputFilterByType DEFLATE application/rss+xml
+ AddOutputFilterByType DEFLATE application/vnd.ms-fontobject
+ AddOutputFilterByType DEFLATE application/x-font
+ AddOutputFilterByType DEFLATE application/x-font-opentype
+ AddOutputFilterByType DEFLATE application/x-font-otf
+ AddOutputFilterByType DEFLATE application/x-font-truetype
+ AddOutputFilterByType DEFLATE application/x-font-ttf
+ AddOutputFilterByType DEFLATE application/x-javascript
+ AddOutputFilterByType DEFLATE application/xhtml+xml
+ AddOutputFilterByType DEFLATE application/xml
+ AddOutputFilterByType DEFLATE application/json
+ AddOutputFilterByType DEFLATE font/opentype
+ AddOutputFilterByType DEFLATE font/otf
+ AddOutputFilterByType DEFLATE font/ttf
+ AddOutputFilterByType DEFLATE image/svg+xml
+ AddOutputFilterByType DEFLATE image/x-icon
+ AddOutputFilterByType DEFLATE text/css
+ AddOutputFilterByType DEFLATE text/html
+ AddOutputFilterByType DEFLATE text/javascript
+ AddOutputFilterByType DEFLATE text/plain
+ AddOutputFilterByType DEFLATE text/xml
+
+
+ # Enable Brotli Compression if available
+
+ AddOutputFilterByType BROTLI_COMPRESS text/plain
+ AddOutputFilterByType BROTLI_COMPRESS text/css
+ AddOutputFilterByType BROTLI_COMPRESS text/xml
+ AddOutputFilterByType BROTLI_COMPRESS text/javascript
+ AddOutputFilterByType BROTLI_COMPRESS application/javascript
+ AddOutputFilterByType BROTLI_COMPRESS application/json
+ AddOutputFilterByType BROTLI_COMPRESS application/xml
+ AddOutputFilterByType BROTLI_COMPRESS application/xhtml+xml
+ AddOutputFilterByType BROTLI_COMPRESS image/svg+xml
+
+
# Handle Authorization Header
RewriteCond %{HTTP:Authorization} .
RewriteRule .* - [E=HTTP_AUTHORIZATION:%{HTTP:Authorization}]
diff --git a/routes/api.php b/routes/api.php
index 7afc416..9f6203d 100644
--- a/routes/api.php
+++ b/routes/api.php
@@ -17,6 +17,7 @@ use App\Http\Controllers\Api\Admin\DnsManager\ServerController as DnsServerContr
use App\Http\Controllers\Api\Admin\DnsManager\DomainController as DnsDomainController;
use App\Http\Controllers\Api\Admin\OsManager\OsGroupController;
use App\Http\Controllers\Api\Admin\OsManager\OsController;
+use App\Http\Controllers\Api\System\CompressionTestController;
use App\Http\Services\Media;
use App\Http\Services\vCenter as ServiceVCenter;
use App\Models\ModuleVmware;
@@ -39,12 +40,12 @@ Route::prefix('v1')
/* Api for Admin */
->group(function () {
Route::prefix('administrator')->group(function () {
- Route::post('login', [AuthController::class, 'login']);
+ Route::post('login', [AuthController::class, 'login']);
Route::middleware('api.auth:admin')->group(function () {
- Route::post('logout', [AuthController::class, 'logout']);
+ Route::post('logout', [AuthController::class, 'logout']);
Route::post('refresh', [AuthController::class, 'refresh']);
- Route::get('me', [AuthController::class, 'me']);
+ Route::get('me', [AuthController::class, 'me']);
// Role Permissions
Route::prefix('role-permissions')->group(function () {
@@ -91,14 +92,14 @@ Route::prefix('v1')
// Products (module: product-service)
Route::prefix('products')->group(function () {
Route::get('export', [ProductController::class, 'export'])->middleware('perm:product-service.manager');
- Route::get('/', [ProductController::class, 'index'])->middleware('perm:product-service.view');
- Route::post('/', [ProductController::class, 'store'])->middleware('perm:product-service.create');
+ Route::get('/', [ProductController::class, 'index'])->middleware('perm:product-service.view');
+ Route::post('/', [ProductController::class, 'store'])->middleware('perm:product-service.create');
Route::post('import', [ProductController::class, 'import'])->middleware('perm:product-service.create');
-
- // OS list for product configuration - must be before {id} routes
- Route::get('os', [ProductController::class, 'getOsList'])->middleware('perm:product-service.view');
-
- Route::get('{id}', [ProductController::class, 'show'])->middleware('perm:product-service.view');
+
+ // OS list for product configuration - must be before {id} routes
+ Route::get('os', [ProductController::class, 'getOsList'])->middleware('perm:product-service.view');
+
+ Route::get('{id}', [ProductController::class, 'show'])->middleware('perm:product-service.view');
Route::get('{id}/os', [ProductController::class, 'getOs'])->middleware('perm:product-service.view');
Route::get('{id}/operating-systems', [ProductController::class, 'getOperatingSystems'])->middleware('perm:product-service.view');
Route::put('{id}', [ProductController::class, 'update'])->middleware('perm:product-service.edit');
@@ -111,24 +112,23 @@ Route::prefix('v1')
->controller(ProductCategoryController::class)
->as('product-categories.')
->group(function () {
- Route::get('/', 'index')->middleware('perm:product-service.view')->name('index');
- Route::post('/', 'store')->middleware('perm:product-service.create')->name('store');
- Route::get('{id}', 'show')->middleware('perm:product-service.view')->name('show');
- Route::put('{id}', 'update')->middleware('perm:product-service.edit')->name('update');
- Route::delete('{id}', 'destroy')->middleware('perm:product-service.destroy')->name('destroy');
- });
+ Route::get('/', 'index')->middleware('perm:product-service.view')->name('index');
+ Route::post('/', 'store')->middleware('perm:product-service.create')->name('store');
+ Route::get('{id}', 'show')->middleware('perm:product-service.view')->name('show');
+ Route::put('{id}', 'update')->middleware('perm:product-service.edit')->name('update');
+ Route::delete('{id}', 'destroy')->middleware('perm:product-service.destroy')->name('destroy');
+ });
- // Orders (module: order)
- Route::prefix('orders')->group(function () {
- Route::get('/', [OrderController::class, 'index'])->middleware('perm:order.view');
- Route::get('/datatable', [OrderController::class, 'datatable'])->middleware('perm:order.view');
- Route::post('/upload-proof', [OrderController::class, 'uploadProof'])->middleware('perm:order.create');
- Route::post('/', [OrderController::class, 'store'])->middleware('perm:order.create');
- Route::get('{id}', [OrderController::class, 'show'])->middleware('perm:order.view');
- Route::put('{id}', [OrderController::class, 'update'])->middleware('perm:order.edit');
- Route::delete('{id}', [OrderController::class, 'destroy'])->middleware('perm:order.destroy');
- Route::post('{id}/accept', [OrderController::class, 'accept'])->middleware('perm:order.edit');
- Route::post('{id}/cancel', [OrderController::class, 'cancel'])->middleware('perm:order.edit');
+ // Orders (module: order)
+ Route::prefix('orders')->controller(OrderController::class)->group(function () {
+ Route::get('/', 'index')->middleware('perm:order.view');
+ Route::post('/upload-proof', 'uploadProof')->middleware('perm:order.create');
+ Route::post('/', 'store')->middleware('perm:order.create');
+ Route::get('{id}', 'show')->middleware('perm:order.view');
+ Route::put('{id}', 'update')->middleware('perm:order.edit');
+ Route::delete('{id}', 'destroy')->middleware('perm:order.destroy');
+ Route::post('{id}/accept', 'accept')->middleware('perm:order.edit');
+ Route::post('{id}/cancel', 'cancel')->middleware('perm:order.edit');
});
// Invoices (module: invoice)
@@ -164,12 +164,12 @@ Route::prefix('v1')
->controller(OsGroupController::class)
->as('os-groups.')
->group(function () {
- Route::get('/', 'index')->middleware('perm:module-vcenter.manager')->name('index');
- Route::post('/', 'store')->middleware('perm:module-vcenter.manager')->name('store');
- Route::get('{id}', 'show')->middleware('perm:module-vcenter.manager')->name('show');
- Route::put('{id}', 'update')->middleware('perm:module-vcenter.manager')->name('update');
- Route::delete('{id}', 'destroy')->middleware('perm:module-vcenter.manager')->name('destroy');
- });
+ Route::get('/', 'index')->middleware('perm:module-vcenter.manager')->name('index');
+ Route::post('/', 'store')->middleware('perm:module-vcenter.manager')->name('store');
+ Route::get('{id}', 'show')->middleware('perm:module-vcenter.manager')->name('show');
+ Route::put('{id}', 'update')->middleware('perm:module-vcenter.manager')->name('update');
+ Route::delete('{id}', 'destroy')->middleware('perm:module-vcenter.manager')->name('destroy');
+ });
Route::prefix('os')
->controller(OsController::class)
@@ -203,22 +203,22 @@ Route::prefix('v1')
// DNS Manager — Domains (module: domain.manager)
Route::prefix('domains')->group(function () {
- Route::get('/', [DnsDomainController::class, 'index'])->middleware('perm:domain.manager');
- Route::post('/', [DnsDomainController::class, 'store'])->middleware('perm:domain.manager');
+ Route::get('/', [DnsDomainController::class, 'index'])->middleware('perm:domain.manager');
+ Route::post('/', [DnsDomainController::class, 'store'])->middleware('perm:domain.manager');
Route::get('{id}', [DnsDomainController::class, 'show'])->middleware('perm:domain.manager');
Route::put('{id}', [DnsDomainController::class, 'update'])->middleware('perm:domain.manager');
Route::delete('{id}', [DnsDomainController::class, 'destroy'])->middleware('perm:domain.manager');
- // DNS Records
- Route::get('{id}/dns', [DnsDomainController::class, 'dnsIndex'])->middleware('perm:domain.manager');
- Route::post('{id}/dns', [DnsDomainController::class, 'dnsStore'])->middleware('perm:domain.manager');
+ // DNS Records
+ Route::get('{id}/dns', [DnsDomainController::class, 'dnsIndex'])->middleware('perm:domain.manager');
+ Route::post('{id}/dns', [DnsDomainController::class, 'dnsStore'])->middleware('perm:domain.manager');
Route::put('{id}/dns/{recordId}', [DnsDomainController::class, 'dnsUpdate'])->middleware('perm:domain.manager');
Route::delete('{id}/dns/{recordId}', [DnsDomainController::class, 'dnsDestroy'])->middleware('perm:domain.manager');
});
// DNS Manager — Servers (module: domain.manager)
Route::prefix('dns-servers')->group(function () {
- Route::get('/', [DnsServerController::class, 'index'])->middleware('perm:domain.manager');
- Route::post('/', [DnsServerController::class, 'store'])->middleware('perm:domain.manager');
+ Route::get('/', [DnsServerController::class, 'index'])->middleware('perm:domain.manager');
+ Route::post('/', [DnsServerController::class, 'store'])->middleware('perm:domain.manager');
Route::get('{id}', [DnsServerController::class, 'show'])->middleware('perm:domain.manager');
Route::put('{id}', [DnsServerController::class, 'update'])->middleware('perm:domain.manager');
Route::delete('{id}', [DnsServerController::class, 'destroy'])->middleware('perm:domain.manager');
@@ -232,9 +232,9 @@ Route::prefix('v1')
// Queue Monitor (Horizon)
Route::prefix('queue')->group(function () {
- Route::get('jobs/recent', [QueueMonitorController::class, 'recent']);
+ Route::get('jobs/recent', [QueueMonitorController::class, 'recent']);
Route::get('jobs/pending', [QueueMonitorController::class, 'pending']);
- Route::get('jobs/failed', [QueueMonitorController::class, 'failed']);
+ Route::get('jobs/failed', [QueueMonitorController::class, 'failed']);
Route::get('jobs/metrics', [QueueMonitorController::class, 'metrics']);
});
@@ -321,6 +321,12 @@ Route::prefix('v1')
});
});
+// Compression Testing Routes (Development/Testing only)
+Route::prefix('v1/system/compression')->middleware(['api'])->group(function () {
+ Route::get('test', [CompressionTestController::class, 'test'])->name('api.compression.test');
+ Route::get('stats', [CompressionTestController::class, 'stats'])->name('api.compression.stats');
+});
+
Route::any('{any}', function () {
return response()->json([
'error' => true,