From 1e09ccf17be4a5f562f95c2a6a09d607b27899f4 Mon Sep 17 00:00:00 2001 From: pbao9 Date: Thu, 12 Mar 2026 09:30:29 +0700 Subject: [PATCH] feat: Reimplement order and invoice management with new service and repository layers, introduce compression middleware, and update database schema. --- app/Enums/Product/ProductBillingCycle.php | 17 + app/Helpers/OrderHelper.php | 30 +- .../Api/Admin/Invoice/InvoiceController.php | 4 +- .../Api/Admin/Order/OrderController.php | 339 +---- .../Api/Service/OrderController.php | 1159 +++++++---------- .../Api/System/CompressionTestController.php | 89 ++ .../Controllers/admin/InvoiceController.php | 4 +- .../Controllers/admin/OrderController.php | 4 +- app/Http/Helpers/Helper.php | 6 +- app/Http/Helpers/function_helper.php | 4 +- app/Http/Kernel.php | 5 +- .../AdvancedCompressionMiddleware.php | 131 ++ app/Http/Middleware/CompressionMiddleware.php | 90 ++ app/Models/Invoice.php | 53 +- app/Models/InvoiceAddons.php | 12 +- app/Models/InvoiceItem.php | 106 +- app/Models/Order.php | 67 +- app/Models/OrderAddon.php | 14 +- app/Models/OrderItem.php | 58 +- app/Models/Service.php | 89 ++ app/Models/ServiceAddon.php | 24 + .../Api/Invoice/InvoiceRepository.php | 111 +- .../Invoice/InvoiceRepositoryInterface.php | 13 +- .../Api/Order/OrderRepository.php | 86 +- .../Api/Order/OrderRepositoryInterface.php | 24 +- .../Invoice/InvoiceRepository.php | 2 +- .../Order/OrderCachedRepository.php | 8 + app/Repositories/Order/OrderRepository.php | 13 + .../Order/OrderRepositoryInterface.php | 1 + app/Services/Api/Order/OrderService.php | 409 ++++-- app/Services/CompressionService.php | 84 ++ app/Services/OrderService.php | 221 ++++ .../2014_10_12_000000_create_users_table.php | 5 +- .../migrations/2024_01_31_152911_domain.php | 43 - .../2024_01_31_155307_discount__customer.php | 18 +- .../migrations/2024_01_31_155621_order.php | 47 - .../migrations/2024_01_31_155824_invoice.php | 51 - .../2024_01_31_164507_domain_type.php | 33 - .../2024_01_31_165505_module_dns_server.php | 2 +- ...024_01_31_165959_module_hosting_server.php | 2 +- .../2024_02_01_080844_module_hosting.php | 2 +- .../migrations/2024_02_01_082942_v_center.php | 2 +- .../2024_02_01_090134_module_datacenter.php | 2 +- .../2024_02_01_091327_module_folder.php | 2 +- .../2024_02_01_094827_module_ipadress.php | 36 +- ...024_02_01_100912_module_snapshot_limit.php | 2 +- .../2024_02_03_095630_module_vmware.php | 27 +- .../2024_02_04_100435_module_snapshot.php | 2 +- .../2024_02_26_124405_order___order_item.php | 42 - .../2024_05_10_090436_staff_online.php | 2 +- .../2025_01_31_160440_invoice_item.php | 41 - .../2025_05_16_215246_create_carts_table.php | 32 - ...05_20_161102_create_order_addons_table.php | 34 - ..._21_154940_create_invoice_addons_table.php | 30 - ..._14_162409_create_product_vps_os_table.php | 33 - ...9_add_customer_id_index_to_carts_table.php | 29 - ...51140_create_edit_column_invoice_table.php | 62 - ...851_update_forigin_key_module_ip_table.php | 30 - ...172000_add_foreign_keys_to_order_table.php | 64 - ...9_120313_create_virtual_machines_table.php | 3 + ...ule_datacenter_id_in_module_host_table.php | 37 - ...module_vcenter_id_in_module_datacenter.php | 33 - ..._093643_update_fk_column_module_folder.php | 48 - ...upport_to_carts_and_order_items_tables.php | 60 - ..._add_host_id_to_virtual_machines_table.php | 29 - ...letes_to_customer_invoice_order_tables.php | 45 - ...0_add_softdeletes_to_transaction_table.php | 21 - ...rop_product_type_from_product_os_table.php | 28 - ..._add_vcenter_id_to_module_vmware_table.php | 27 - ...100000_add_auto_renew_to_invoice_table.php | 25 - ..._02_111537_update_column_invoice_table.php | 28 - ...ule_host_id_to_module_ip_address_table.php | 32 - ...d_birthday_address_phone_to_user_table.php | 24 - ...ent_method_to_invoice_and_order_tables.php | 31 - ...00002_add_payment_proof_to_order_table.php | 21 - ..._03_11_144540_rename_order_items_table.php | 24 - ...5_add_expiry_date_to_order_items_table.php | 28 - .../2026_03_11_200001_create_orders_table.php | 46 + ..._03_11_200002_create_order_items_table.php | 46 + ...026_03_11_200003_create_invoices_table.php | 68 + ...3_11_200004_create_invoice_items_table.php | 46 + ...026_03_11_200005_create_services_table.php | 61 + ..._11_200006_create_service_addons_table.php | 45 + ...0007_create_invoice_attachments_table.php} | 14 +- database/seeders/DatabaseSeeder.php | 8 +- public/.htaccess | 40 + routes/api.php | 90 +- 87 files changed, 2348 insertions(+), 2612 deletions(-) create mode 100644 app/Http/Controllers/Api/System/CompressionTestController.php create mode 100644 app/Http/Middleware/AdvancedCompressionMiddleware.php create mode 100644 app/Http/Middleware/CompressionMiddleware.php create mode 100644 app/Models/Service.php create mode 100644 app/Models/ServiceAddon.php create mode 100644 app/Services/CompressionService.php create mode 100644 app/Services/OrderService.php delete mode 100644 database/migrations/2024_01_31_152911_domain.php delete mode 100644 database/migrations/2024_01_31_155621_order.php delete mode 100644 database/migrations/2024_01_31_155824_invoice.php delete mode 100644 database/migrations/2024_01_31_164507_domain_type.php delete mode 100644 database/migrations/2024_02_26_124405_order___order_item.php delete mode 100644 database/migrations/2025_01_31_160440_invoice_item.php delete mode 100644 database/migrations/2025_05_16_215246_create_carts_table.php delete mode 100644 database/migrations/2025_05_20_161102_create_order_addons_table.php delete mode 100644 database/migrations/2025_05_21_154940_create_invoice_addons_table.php delete mode 100644 database/migrations/2025_07_14_162409_create_product_vps_os_table.php delete mode 100644 database/migrations/2025_07_15_084129_add_customer_id_index_to_carts_table.php delete mode 100644 database/migrations/2025_07_15_151140_create_edit_column_invoice_table.php delete mode 100644 database/migrations/2026_01_27_151851_update_forigin_key_module_ip_table.php delete mode 100644 database/migrations/2026_01_27_172000_add_foreign_keys_to_order_table.php delete mode 100644 database/migrations/2026_01_30_170136_update_module_datacenter_id_in_module_host_table.php delete mode 100644 database/migrations/2026_01_30_170804_update_module_vcenter_id_in_module_datacenter.php delete mode 100644 database/migrations/2026_01_31_093643_update_fk_column_module_folder.php delete mode 100644 database/migrations/2026_02_08_210451_add_product_support_to_carts_and_order_items_tables.php delete mode 100644 database/migrations/2026_02_15_121859_add_host_id_to_virtual_machines_table.php delete mode 100644 database/migrations/2026_02_16_000000_add_softdeletes_to_customer_invoice_order_tables.php delete mode 100644 database/migrations/2026_02_16_120000_add_softdeletes_to_transaction_table.php delete mode 100644 database/migrations/2026_03_01_184412_drop_product_type_from_product_os_table.php delete mode 100644 database/migrations/2026_03_01_223038_add_vcenter_id_to_module_vmware_table.php delete mode 100644 database/migrations/2026_03_02_100000_add_auto_renew_to_invoice_table.php delete mode 100644 database/migrations/2026_03_02_111537_update_column_invoice_table.php delete mode 100644 database/migrations/2026_03_04_100634_add_module_host_id_to_module_ip_address_table.php delete mode 100644 database/migrations/2026_03_10_000001_add_birthday_address_phone_to_user_table.php delete mode 100644 database/migrations/2026_03_11_000001_add_payment_method_to_invoice_and_order_tables.php delete mode 100644 database/migrations/2026_03_11_000002_add_payment_proof_to_order_table.php delete mode 100644 database/migrations/2026_03_11_144540_rename_order_items_table.php delete mode 100644 database/migrations/2026_03_11_153935_add_expiry_date_to_order_items_table.php create mode 100644 database/migrations/2026_03_11_200001_create_orders_table.php create mode 100644 database/migrations/2026_03_11_200002_create_order_items_table.php create mode 100644 database/migrations/2026_03_11_200003_create_invoices_table.php create mode 100644 database/migrations/2026_03_11_200004_create_invoice_items_table.php create mode 100644 database/migrations/2026_03_11_200005_create_services_table.php create mode 100644 database/migrations/2026_03_11_200006_create_service_addons_table.php rename database/migrations/{2026_03_10_000001_create_invoice_attachments_table.php => 2026_03_11_200007_create_invoice_attachments_table.php} (59%) 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,