From 61c0e026c87088eb04b62d7c5a123cd9b21bee8c Mon Sep 17 00:00:00 2001 From: baodev0 Date: Thu, 9 Jul 2026 11:19:50 +0700 Subject: [PATCH] update --- .DS_Store | Bin 10244 -> 10244 bytes app/Console/Commands/CheckOverdueInvoices.php | 91 +++++++ app/Console/Commands/ServiceExpiry.php | 87 ++++++- app/Console/Kernel.php | 6 + app/Enums/Invoice/InvoiceStatus.php | 4 + .../Api/Admin/Customer/CustomerController.php | 15 +- .../EmailTemplate/EmailTemplateController.php | 6 + .../Api/Admin/Invoice/InvoiceController.php | 99 ++++++-- .../Api/Service/OrderControllerRefactor.php | 46 ++++ .../Api/Setting/SettingController.php | 13 + app/Http/Requests/CreateCustomerRequest.php | 1 + .../Resources/Api/Admin/CustomerResource.php | 1 + .../Resources/Api/Admin/InvoiceResource.php | 24 +- .../Resources/Api/Admin/OrderResource.php | 38 +-- app/Listeners/Mail/CheckMailEnabled.php | 31 +++ app/Mail/InvoiceNotificationMail.php | 26 +- app/Models/Customer.php | 1 + app/Providers/EventServiceProvider.php | 5 + .../Api/Invoice/InvoiceRepository.php | 2 +- app/Services/Api/Order/OrderQueryService.php | 101 +++++--- app/Services/Api/Order/OrderService.php | 29 +-- app/Services/InvoicePaymentService.php | 227 ++++++++++++++++++ app/Services/OrderService.php | 42 +++- composer.lock | 166 ++++++------- ..._09_000001_add_note_to_customers_table.php | 26 ++ .../views/emails/customer/welcome.blade.php | 14 +- .../emails/invoice-notification.blade.php | 59 ++++- routes/client.php | 9 +- 28 files changed, 939 insertions(+), 230 deletions(-) create mode 100644 app/Console/Commands/CheckOverdueInvoices.php create mode 100644 app/Listeners/Mail/CheckMailEnabled.php create mode 100644 app/Services/InvoicePaymentService.php create mode 100644 database/migrations/2026_07_09_000001_add_note_to_customers_table.php diff --git a/.DS_Store b/.DS_Store index 47fb12f186c27fac6821df179b5a46cee1b08c29..c579f32909581bc2097848aa63ab0453423ae5f0 100644 GIT binary patch literal 10244 zcmeI1PiS049LIl?Y<9P)7R5pdczB2h>!DcbrGm27l!~E938)lllkBdWl-+&nHmOtu z7b_mL7X=j*u`0Q=MItIFk+LXKy{NagS%lV#Ry|mSh#o3_e>1ahfAjuCFM^hN@ZN0l z`_BA6`<;37-pmq_+ObgCN)!-L5hs_|U4u)?!Ertl$B_NcUX%*;iJCM|2kW)1ljSg> z_W$q>cn7=#-U07`ci_L^0G`==6xW;5U%dm~0q;QM0M8ErPA*Hf?3hwd9k}Qv0LmQx ztq*+0IzZP6Y+15p$CSD%+BB;NWveQ?#89R>;sMK1mTcKErKwKJR3~LKE4xBbIy>qG zT288DN`LhZcn6XW@Y=mVTWFeURF&)ZMoWv;wY{XG5sr_~)WaD>ilcA0&Md8*zB^4- z`3I`jEbkWdvvtu-oafMFyIBvLHAML4XWBB=LA*c}s+!$~e*H~Hk_t)t!_&Q!U;Z4? zigp^|fHqs@(td?xn7_8byE&SdB!!1tyJSocek!{wO*a56P&L@6OBIAM0oa-#p|U zFr019?&PKem2{QlIDE9$d$puFQreSVtgUqT=os_mbH<&@}v-hF9(#T%7tPO(S{Uyyva_&EIH`5^YJQwR8^G zhHffXY~IO%JA{XJf78x5^w1Ni9K|H(^TV~xFMp>wMSL)-(&7#mhOcg$=elOB&(zK@ zWK8CrcxLIf<+C=gtt)*=i#vH$m-FVSfhR%(-?>ePo$*5JF2&rRpTxUH30mKQnVqhITMk{!Z&~8sGBZ#B%|3b5xdmxsN)#!RN#!*Bu{k zI(?JjifINsSsKYPvi%s+U|wu9N_(;|*H%hC zy8_9BirMB9WYoI|GAAyXF8}uI-q+1@Jf<1=l%;Vv%$lVDkIG<~L%&78qV#8(jW>cM zrjRVXPojG+p&HO+s~jG>4as!2d42cmcSAXX)bSVZO)4bM`s?2~Y3DxX5$(vn%2r7p zwXC17Bf1?P(Xlz8at;==>CF%ihR6uy{i}E*Va}xAg9r{x}tFNQx|teos8xn#?ZDRp&dbaj+wH~(M%F`z2nYiL84VG?@@ Ze*KU4O~3w+?@p(#)#dv*(f?un{}=tVw(I}^ delta 1016 zcmZn(XbG6$FY3m?z`)4BAi%(o$56shTozoEmy^G-a2or>2Hwr=94s7+ZjB!cJl$>-t4Y3YQ4Z>20n#mC&3XB4i1w}t9SBjdd>3HO2gG>R3bPh07@<7f+H~}I*nMF)_@^kTJ%na=ulP5@&Gci1v z%qc0))UP&KOHzjI-Yg~S&&MbGNXo&O1wcuL1Cu98Dyj*_0?lS;CZ5ik%6K&c5i zAeN+fopG<6d_z)!4a)o{sl=!-Sy4(}gGsdMIaGHEFc^z~?n`G#Mb`a!Cr|Xu6O%Kf z6xg85J}CueCN+l1hSHX7#=7}H-%T!%mVq%RN!xlx?k)qzOc_He(7P!hgOQDNzja}q t$P;jourZ*hK?Dm#&14A~d8U8|8w=;LZDv>a#WH!KsQ6F_R}pOC3IJHHEE50# diff --git a/app/Console/Commands/CheckOverdueInvoices.php b/app/Console/Commands/CheckOverdueInvoices.php new file mode 100644 index 0000000..13b1dc3 --- /dev/null +++ b/app/Console/Commands/CheckOverdueInvoices.php @@ -0,0 +1,91 @@ +toDateString(); + $dryRun = (bool) $this->option('dry-run'); + + $overdueByStatus = Invoice::query() + ->whereNotNull('due_date') + ->whereDate('due_date', '<', $today) + ->selectRaw('status, count(*) as total') + ->groupBy('status') + ->orderBy('status') + ->get(); + + if ($overdueByStatus->isNotEmpty()) { + $this->line('invoices:check-overdue | Past-due invoices by current status:'); + foreach ($overdueByStatus as $row) { + $label = InvoiceStatus::tryFrom((int) $row->status)?->label() ?? 'Không xác định'; + $this->line(" - {$label} ({$row->status}): {$row->total}"); + } + } + + $invoices = Invoice::query() + ->with('order:id,status') + ->whereNotNull('due_date') + ->whereDate('due_date', '<', $today) + ->whereIn('status', [InvoiceStatus::UNPAID->value, InvoiceStatus::OVERDUE->value]) + ->get(); + + $this->info("invoices:check-overdue | Found {$invoices->count()} invoice(s) due before {$today}."); + + $updatedInvoices = 0; + $updatedOrders = 0; + + foreach ($invoices as $invoice) { + $needsInvoiceUpdate = (int) $invoice->status !== InvoiceStatus::OVERDUE->value; + $order = $invoice->order; + $needsOrderUpdate = $order + && ! in_array((int) $order->status, [OrderStatus::PENDING->value, OrderStatus::CANCELLED->value], true); + + $this->line(sprintf( + ' [%s] Invoice #%s due %s%s', + $dryRun ? 'dry-run' : 'check', + $invoice->invoice_number, + $invoice->due_date?->format('Y-m-d') ?? '-', + $order ? " | order #{$invoice->order_id}" : '' + )); + + if ($dryRun || (! $needsInvoiceUpdate && ! $needsOrderUpdate)) { + continue; + } + + DB::transaction(function () use ($invoice, $order, $needsInvoiceUpdate, $needsOrderUpdate, &$updatedInvoices, &$updatedOrders) { + if ($needsInvoiceUpdate) { + $invoice->update(['status' => InvoiceStatus::OVERDUE->value]); + $updatedInvoices++; + } + + if ($needsOrderUpdate && $order) { + $order->update(['status' => OrderStatus::PENDING->value]); + $updatedOrders++; + } + }); + + Log::info('Invoice marked overdue', [ + 'invoice_id' => $invoice->id, + 'invoice_number' => $invoice->invoice_number, + 'order_id' => $invoice->order_id, + ]); + } + + $this->info("invoices:check-overdue | Updated {$updatedInvoices} invoice(s), {$updatedOrders} order(s)."); + + return Command::SUCCESS; + } +} diff --git a/app/Console/Commands/ServiceExpiry.php b/app/Console/Commands/ServiceExpiry.php index cbd0cf4..a59b60f 100644 --- a/app/Console/Commands/ServiceExpiry.php +++ b/app/Console/Commands/ServiceExpiry.php @@ -3,10 +3,13 @@ namespace App\Console\Commands; use App\Enums\Invoice\InvoiceStatus; +use App\Enums\Order\OrderStatus; use App\Helpers\OrderHelper; use App\Jobs\VirtualMachine\Action\VMJobs; use App\Models\Invoice; use App\Models\InvoiceItem; +use App\Models\Order; +use App\Models\OrderItem; use App\Models\Service; use Illuminate\Console\Command; use Illuminate\Support\Facades\DB; @@ -14,11 +17,35 @@ use Illuminate\Support\Facades\Log; class ServiceExpiry extends Command { - protected $signature = 'service:expiry'; + protected $signature = 'service:expiry {--dry-run : Preview service expiry actions without updating data}'; protected $description = 'Suspend expired services, power off VMs, create renewal invoices, terminate after 30 days'; public function handle(): int { + $dryRun = (bool) $this->option('dry-run'); + $renewalWindowDays = 7; + $upcoming = Service::query() + ->with(['orderItem.product']) + ->whereNotNull('expires_at') + ->whereBetween('expires_at', [now(), now()->addDays($renewalWindowDays)]) + ->whereNotIn('status', ['terminated', 'cancelled']) + ->get(); + + $this->info("service:expiry | Found {$upcoming->count()} service(s) expiring in {$renewalWindowDays} day(s)."); + + foreach ($upcoming as $service) { + try { + if ($dryRun) { + $this->createRenewalInvoiceIfMissing($service, true); + } else { + DB::transaction(fn () => $this->createRenewalInvoiceIfMissing($service)); + } + } catch (\Throwable $e) { + Log::error('service:expiry upcoming renewal error', ['service_id' => $service->id, 'error' => $e->getMessage()]); + $this->error(" [error] Service #{$service->id}: " . $e->getMessage()); + } + } + // Note: 'invoice' is NOT eager-loaded — createRenewalInvoiceIfMissing queries fresh $expired = Service::query() ->with(['orderItem.product']) @@ -38,6 +65,11 @@ class ServiceExpiry extends Command // --- 30-day threshold: terminate (idempotent) --- if ($daysExpired >= 30) { + if ($dryRun) { + $this->line(" [dry-run:terminate] Service #{$service->id}"); + continue; + } + if ($service->status !== 'terminated') { $service->update(['status' => 'terminated']); Log::info('Service terminated', ['service_id' => $service->id, 'days_overdue' => $daysExpired]); @@ -49,6 +81,12 @@ class ServiceExpiry extends Command // Capture before transaction so dispatch is conditional and outside the transaction $needsVmPowerOff = $service->status !== 'suspended' && $service->vm_id; + if ($dryRun) { + $this->line(" [dry-run:suspend] Service #{$service->id}"); + $this->createRenewalInvoiceIfMissing($service, true); + continue; + } + // --- Transaction: status update + invoice creation (atomic) --- DB::transaction(function () use ($service) { if ($service->status !== 'suspended') { @@ -68,7 +106,6 @@ class ServiceExpiry extends Command $this->createRenewalInvoiceIfMissing($service); }); - // --- Dispatch VMJobs OUTSIDE transaction (safe: DB is committed) --- if ($needsVmPowerOff) { dispatch(new VMJobs((int) $service->vm_id, 'PowerOff', false)); Log::info('PowerOff dispatched', ['service_id' => $service->id, 'vm_id' => $service->vm_id]); @@ -84,16 +121,17 @@ class ServiceExpiry extends Command return Command::SUCCESS; } - private function createRenewalInvoiceIfMissing(Service $service): void + private function createRenewalInvoiceIfMissing(Service $service, bool $dryRun = false): void { // Fresh DB query via invoice_id FK — does NOT rely on stale eager-loaded relation. // invoices table has no service_id column; service->invoice_id is the correct pointer. $hasUnpaidRenewal = $service->invoice_id && Invoice::where('id', $service->invoice_id) ->where('invoice_type', 2) - ->where('status', InvoiceStatus::UNPAID->value) + ->whereIn('status', [InvoiceStatus::UNPAID->value, InvoiceStatus::OVERDUE->value]) ->exists(); if ($hasUnpaidRenewal) { + $this->line(" [skip] Service #{$service->id} already has open renewal invoice."); return; } @@ -105,17 +143,46 @@ class ServiceExpiry extends Command $price = (int) $orderItem->price; + if ($dryRun) { + $this->line(" [dry-run:invoice] Would create renewal order + invoice for Service #{$service->id}, amount " . number_format($price) . ' VND.'); + return; + } + + $order = Order::create([ + 'order_num' => OrderHelper::generateOrderNumber(), + 'customer_id' => $service->customer_id, + 'sub_total' => $price, + 'discount_amount' => 0, + 'total' => $price, + 'status' => OrderStatus::PENDING->value, + 'client_ip' => null, + 'note' => 'Đơn hàng gia hạn dịch vụ #' . $service->id, + ]); + + OrderItem::create([ + 'order_id' => $order->id, + 'product_id' => $service->product_id, + 'billing_cycle' => $orderItem->billing_cycle, + 'months' => $orderItem->months ?? 1, + 'os_id' => $orderItem->os_id, + 'quantity' => 1, + 'server_name' => $service->server_name, + 'price' => $price, + ]); + $invoice = Invoice::create([ 'invoice_number' => OrderHelper::generateOrderNumber('INV'), 'customer_id' => $service->customer_id, - 'order_id' => null, + 'order_id' => $order->id, 'invoice_type' => 2, // renewal 'sub_total' => $price, 'discount_amount' => 0, 'tax' => 0, 'total' => $price, 'status' => InvoiceStatus::UNPAID->value, - 'due_date' => now()->addDays(30)->toDateString(), + 'due_date' => $service->expires_at && $service->expires_at->isFuture() + ? $service->expires_at->toDateString() + : now()->addDays(7)->toDateString(), ]); InvoiceItem::create([ @@ -132,7 +199,11 @@ class ServiceExpiry extends Command // Update service to point to the new renewal invoice $service->update(['invoice_id' => $invoice->id]); - Log::info('Renewal invoice created', ['service_id' => $service->id, 'invoice_number' => $invoice->invoice_number]); - $this->line(" [invoice] Renewal {$invoice->invoice_number} for Service #{$service->id}."); + Log::info('Renewal order and invoice created', [ + 'service_id' => $service->id, + 'order_num' => $order->order_num, + 'invoice_number' => $invoice->invoice_number, + ]); + $this->line(" [invoice] Renewal {$order->order_num} / {$invoice->invoice_number} for Service #{$service->id}."); } } diff --git a/app/Console/Kernel.php b/app/Console/Kernel.php index 58c41dc..a0aad6d 100644 --- a/app/Console/Kernel.php +++ b/app/Console/Kernel.php @@ -43,6 +43,12 @@ class Kernel extends ConsoleKernel ->timezone("Asia/Ho_Chi_Minh") ->everyFiveMinutes() ->withoutOverlapping(); + + /* Mark overdue unpaid invoices */ + $schedule->command('invoices:check-overdue') + ->timezone("Asia/Ho_Chi_Minh") + ->dailyAt("00:10") + ->withoutOverlapping(); } /** diff --git a/app/Enums/Invoice/InvoiceStatus.php b/app/Enums/Invoice/InvoiceStatus.php index 514bac6..92b225b 100644 --- a/app/Enums/Invoice/InvoiceStatus.php +++ b/app/Enums/Invoice/InvoiceStatus.php @@ -7,6 +7,8 @@ enum InvoiceStatus: int case UNPAID = 1; case PAID = 2; case CANCELLED = 3; + case OVERDUE = 4; + case REFUNDED = 5; public function label(): string { @@ -14,6 +16,8 @@ enum InvoiceStatus: int self::UNPAID => 'Chưa thanh toán', self::PAID => 'Đã thanh toán', self::CANCELLED => 'Đã hủy', + self::OVERDUE => 'Quá hạn', + self::REFUNDED => 'Đã hoàn tiền', }; } } diff --git a/app/Http/Controllers/Api/Admin/Customer/CustomerController.php b/app/Http/Controllers/Api/Admin/Customer/CustomerController.php index 39d11cd..21c68a0 100644 --- a/app/Http/Controllers/Api/Admin/Customer/CustomerController.php +++ b/app/Http/Controllers/Api/Admin/Customer/CustomerController.php @@ -87,6 +87,12 @@ class CustomerController extends Controller $data['email_marketing'] = $request->email_marketing === "on" ? 1 : 0; $customer = $this->customerRepository->create($data); + if ($request->exists('note')) { + Customer::query() + ->whereKey($customer->id) + ->update(['note' => $request->input('note')]); + $customer = Customer::query()->find($customer->id); + } DB::commit(); return response()->json([ @@ -142,6 +148,8 @@ class CustomerController extends Controller ], 404); } + $customer->refresh(); + return response()->json([ 'error' => false, 'data' => new CustomerResource($customer) @@ -170,12 +178,17 @@ class CustomerController extends Controller } $this->customerRepository->update($customer->id, $data); + if ($request->exists('note')) { + Customer::query() + ->whereKey($customer->id) + ->update(['note' => $request->input('note')]); + } DB::commit(); return response()->json([ 'error' => false, 'message' => 'Customer updated successfully', - 'data' => new CustomerResource($this->customerRepository->find($customer->id)) + 'data' => new CustomerResource(Customer::query()->find($customer->id)) ]); } catch (\Exception $e) { DB::rollBack(); diff --git a/app/Http/Controllers/Api/Admin/EmailTemplate/EmailTemplateController.php b/app/Http/Controllers/Api/Admin/EmailTemplate/EmailTemplateController.php index d839fd0..00bd8f1 100644 --- a/app/Http/Controllers/Api/Admin/EmailTemplate/EmailTemplateController.php +++ b/app/Http/Controllers/Api/Admin/EmailTemplate/EmailTemplateController.php @@ -96,6 +96,11 @@ class EmailTemplateController extends Controller $setting = Setting::where('key', 'email_settings')->first(); $value = $setting ? json_decode($setting->value, true) : []; + // Ensure mail_enabled defaults to true when not set + if (!isset($value['mail_enabled'])) { + $value['mail_enabled'] = true; + } + return response()->json(['error' => false, 'data' => $value]); } @@ -106,6 +111,7 @@ class EmailTemplateController extends Controller 'renewal_template_id' => 'nullable|integer|exists:template,id', 'news_template_id' => 'nullable|integer|exists:template,id', 'invoice_template_id' => 'nullable|integer|exists:template,id', + 'mail_enabled' => 'sometimes|boolean', ]); $setting = Setting::firstOrNew(['key' => 'email_settings']); diff --git a/app/Http/Controllers/Api/Admin/Invoice/InvoiceController.php b/app/Http/Controllers/Api/Admin/Invoice/InvoiceController.php index a613fcf..376edd6 100644 --- a/app/Http/Controllers/Api/Admin/Invoice/InvoiceController.php +++ b/app/Http/Controllers/Api/Admin/Invoice/InvoiceController.php @@ -2,6 +2,7 @@ namespace App\Http\Controllers\Api\Admin\Invoice; +use App\Enums\Invoice\InvoiceStatus; use App\Http\Controllers\Controller; use App\Http\Requests\InvoiceRequest; use App\Http\Resources\Api\Admin\InvoiceResource; @@ -12,11 +13,15 @@ use App\Repositories\Invoice\InvoiceRepositoryInterface; use App\Repositories\InvoiceAttachment\InvoiceAttachmentRepositoryInterface; use App\Repositories\InvoiceItem\InvoiceItemRepositoryInterface; use App\Repositories\Order\OrderRepositoryInterface; +use App\Services\InvoicePaymentService; +use App\Services\InvoicePdfService; use Illuminate\Http\JsonResponse; use Illuminate\Http\Request; use Illuminate\Support\Facades\DB; +use Illuminate\Support\Facades\Log; use Illuminate\Support\Facades\Mail; use Illuminate\Support\Facades\Storage; +use Throwable; use Yajra\DataTables\Facades\DataTables; class InvoiceController extends Controller @@ -26,13 +31,15 @@ class InvoiceController extends Controller private readonly OrderRepositoryInterface $orderRepository, private readonly InvoiceItemRepositoryInterface $invoiceItemRepository, private readonly InvoiceAttachmentRepositoryInterface $attachmentRepository, + private readonly InvoicePdfService $invoicePdfService, + private readonly InvoicePaymentService $invoicePaymentService, ) {} public function index(Request $request): JsonResponse { $query = $this->invoiceRepository->filter( $request->all(), - ['payment', 'customer'], + ['payment', 'customer', 'order'], [], ['*'], true @@ -51,7 +58,7 @@ class InvoiceController extends Controller { $query = $this->invoiceRepository->filter( $request->all(), - ['payment', 'customer'], + ['payment', 'customer', 'order'], [], ['*'], true @@ -138,18 +145,33 @@ class InvoiceController extends Controller $request->validated(); - $status = match (strtolower($request->get('action'))) { - "paid" => 1, - "cancel" => 2, - default => 0 + $action = strtolower((string) $request->get('action')); + $status = match ($action) { + 'paid' => InvoiceStatus::PAID->value, + 'cancel' => InvoiceStatus::CANCELLED->value, + default => null, }; + if ($status === null) { + return response()->json([ + 'error' => true, + 'message' => 'Invalid invoice action.', + ], 422); + } + try { DB::beginTransaction(); - $this->invoiceRepository->update($id, [ - 'status' => $status, - 'date_paid' => $status === 1 ? time() : null - ]); + $invoice->load(['order', 'customer']); + + if ($status === InvoiceStatus::PAID->value) { + $this->invoicePaymentService->markPaidByAdmin($invoice, auth()->id()); + } else { + $invoice->update([ + 'status' => $status, + 'paid_at' => null, + ]); + } + DB::commit(); return response()->json([ @@ -208,10 +230,10 @@ class InvoiceController extends Controller try { DB::beginTransaction(); - $this->invoiceRepository->update($id, [ - 'status' => 1, - 'date_paid' => time() - ]); + + $invoice->load(['order', 'customer']); + $this->invoicePaymentService->markPaidByAdmin($invoice, auth()->id()); + DB::commit(); return response()->json([ @@ -240,9 +262,9 @@ class InvoiceController extends Controller try { DB::beginTransaction(); - $this->invoiceRepository->update($id, [ - 'status' => 2, - 'date_paid' => null + $invoice->update([ + 'status' => InvoiceStatus::CANCELLED->value, + 'paid_at' => null ]); DB::commit(); @@ -334,6 +356,7 @@ class InvoiceController extends Controller { $request->validate([ 'message' => 'nullable|string|max:1000', + 'type' => 'nullable|string|in:invoice,renewal', ]); $invoice = $this->invoiceRepository->find($id); @@ -349,11 +372,45 @@ class InvoiceController extends Controller $attachments = $this->attachmentRepository->getByInvoice($id); $companyName = setting('general.company_name') ?: config('app.name'); - $message = $request->get('message', "Vui lòng xem thông tin hóa đơn #{$invoice->order_num} của bạn."); - - Mail::to($customer->email)->queue( - new InvoiceNotificationMail($invoice, $attachments, $customer->fullname, $message, $companyName) + $type = $request->get('type', 'invoice'); + $message = $request->get( + 'message', + $type === 'renewal' + ? "Dịch vụ của quý khách sắp đến hạn gia hạn. Vui lòng kiểm tra hóa đơn #{$invoice->order_num} được đính kèm và hoàn tất thanh toán để dịch vụ không bị gián đoạn." + : "Vui lòng xem thông tin hóa đơn #{$invoice->order_num} của bạn." ); + $paymentUrl = url('/client/invoices/' . $invoice->invoice_number . '/payment'); + try { + $invoicePdf = $this->invoicePdfService->generate($invoice); + $invoicePdfFileName = $this->invoicePdfService->generateFileName($invoice); + + Mail::to($customer->email)->send( + new InvoiceNotificationMail( + $invoice, + $attachments, + $customer->fullname, + $message, + $companyName, + $type, + $invoicePdf, + $invoicePdfFileName, + $paymentUrl + ) + ); + } catch (Throwable $e) { + Log::error('InvoiceController@sendMail failed', [ + 'invoice_id' => $id, + 'invoice_number' => $invoice->invoice_number ?? null, + 'customer_email' => $customer->email, + 'type' => $type, + 'error' => $e->getMessage(), + ]); + + return response()->json([ + 'error' => true, + 'message' => 'Không thể gửi email: ' . $e->getMessage(), + ], 500); + } return response()->json([ 'error' => false, diff --git a/app/Http/Controllers/Api/Service/OrderControllerRefactor.php b/app/Http/Controllers/Api/Service/OrderControllerRefactor.php index 85a4c3e..e05f143 100644 --- a/app/Http/Controllers/Api/Service/OrderControllerRefactor.php +++ b/app/Http/Controllers/Api/Service/OrderControllerRefactor.php @@ -19,6 +19,7 @@ use App\Repositories\Api\Order\OrderRepository; use App\Services\ActivityLogger; use App\Services\Api\Order\OrderQueryService; use App\Services\Api\Order\OrderService; +use App\Services\InvoicePaymentService; use Illuminate\Http\JsonResponse; use Illuminate\Http\Request; @@ -29,6 +30,7 @@ class OrderControllerRefactor extends Controller private readonly OrderRepository $orderRepository, private readonly OrderQueryService $orderQueryService, private readonly ActivityLogger $activityLogger, + private readonly InvoicePaymentService $invoicePaymentService, ) { } @@ -179,6 +181,50 @@ class OrderControllerRefactor extends Controller return response()->json(['success' => true, 'data' => new InvoiceDetailResource($order->invoice->setRelation('order', $order))]); } + public function getClientInvoiceDetailByNumber(Request $request, string $invoiceNumber): JsonResponse + { + $invoice = $this->orderQueryService->findCustomerInvoiceByNumber((int) $request->user()->id, $invoiceNumber); + + if (! $invoice) { + return response()->json(['success' => false, 'message' => 'Hóa đơn không tồn tại.'], 404); + } + + return response()->json([ + 'success' => true, + 'data' => new InvoiceDetailResource($invoice), + 'payment_url' => url('/client/invoices/' . $invoice->invoice_number . '/payment'), + ]); + } + + public function payClientInvoiceByNumber(Request $request, string $invoiceNumber): JsonResponse + { + $invoice = $this->orderQueryService->findCustomerInvoiceByNumber((int) $request->user()->id, $invoiceNumber); + + if (! $invoice) { + return response()->json(['success' => false, 'message' => 'Hóa đơn không tồn tại.'], 404); + } + + try { + $paidInvoice = $this->invoicePaymentService->payWithBalance($invoice, $request->user(), 'balance'); + + $this->activityLogger->log( + event: 'payment', + logName: 'Invoice', + description: "Customer {$request->user()->email} thanh toán hóa đơn #{$paidInvoice->invoice_number}", + subject: $paidInvoice, + newValues: ['invoice_number' => $paidInvoice->invoice_number, 'total' => $paidInvoice->total], + ); + + return response()->json([ + 'success' => true, + 'message' => 'Thanh toán hóa đơn thành công.', + 'data' => new InvoiceDetailResource($paidInvoice), + ]); + } catch (\Throwable $e) { + return response()->json(['success' => false, 'message' => $e->getMessage()], 422); + } + } + public function getClientServices(Request $request): JsonResponse { $services = $this->orderQueryService->getCustomerServices((int) $request->user()->id); diff --git a/app/Http/Controllers/Api/Setting/SettingController.php b/app/Http/Controllers/Api/Setting/SettingController.php index d84f031..778ecdc 100644 --- a/app/Http/Controllers/Api/Setting/SettingController.php +++ b/app/Http/Controllers/Api/Setting/SettingController.php @@ -40,4 +40,17 @@ class SettingController extends Controller 'error' => false ]); } + + public function getEmailSetting(): \Illuminate\Http\JsonResponse + { + $setting = Setting::where('key', 'email_settings')->first(); + $value = $setting ? json_decode($setting->value, true) : []; + + return response()->json([ + 'error' => false, + 'data' => [ + 'mail_enabled' => $value['mail_enabled'] ?? true, + ], + ]); + } } \ No newline at end of file diff --git a/app/Http/Requests/CreateCustomerRequest.php b/app/Http/Requests/CreateCustomerRequest.php index 8b37023..2dfc72f 100644 --- a/app/Http/Requests/CreateCustomerRequest.php +++ b/app/Http/Requests/CreateCustomerRequest.php @@ -33,6 +33,7 @@ class CreateCustomerRequest extends FormRequest 'postcode' => 'nullable', 'country' => 'nullable', 'email_marketing' => 'nullable', + 'note' => 'nullable|string', ]; } } diff --git a/app/Http/Resources/Api/Admin/CustomerResource.php b/app/Http/Resources/Api/Admin/CustomerResource.php index 0cb93b9..1ae0610 100644 --- a/app/Http/Resources/Api/Admin/CustomerResource.php +++ b/app/Http/Resources/Api/Admin/CustomerResource.php @@ -16,6 +16,7 @@ class CustomerResource extends JsonResource 'email' => $this->email, 'phone' => $this->phone, 'company' => $this->company, + 'note' => $this->note, 'status' => (int) $this->status, 'total' => (int) $this->total, 'created_at' => $this->created_at, diff --git a/app/Http/Resources/Api/Admin/InvoiceResource.php b/app/Http/Resources/Api/Admin/InvoiceResource.php index cba47c6..2a476ca 100644 --- a/app/Http/Resources/Api/Admin/InvoiceResource.php +++ b/app/Http/Resources/Api/Admin/InvoiceResource.php @@ -10,21 +10,23 @@ class InvoiceResource extends JsonResource public function toArray(Request $request): array { return [ - 'id' => $this->id, - 'order_num' => $this->order_num, - 'customer' => $this->whenLoaded('customer', fn() => [ + 'id' => $this->id, + 'invoice_number' => $this->invoice_number, + 'order_num' => $this->whenLoaded('order', fn() => $this->order?->order_num), + 'customer' => $this->whenLoaded('customer', fn() => [ 'id' => $this->customer->id, 'fullname' => $this->customer->fullname, 'email' => $this->customer->email, ]), - 'sub_total' => $this->sub_total, - 'tax' => $this->tax, - 'total' => $this->total, - 'status' => $this->status, - 'date' => $this->date, - 'due_date' => $this->due_date, - 'date_paid' => $this->date_paid, - 'created_at' => $this->created_at, + 'sub_total' => $this->sub_total, + 'discount_amount' => $this->discount_amount, + 'tax' => $this->tax, + 'total' => $this->total, + 'status' => $this->status, + 'due_date' => $this->due_date, + 'paid_at' => $this->paid_at, + 'created_at' => $this->created_at, + 'updated_at' => $this->updated_at, ]; } } diff --git a/app/Http/Resources/Api/Admin/OrderResource.php b/app/Http/Resources/Api/Admin/OrderResource.php index fe128fe..1fee9a4 100644 --- a/app/Http/Resources/Api/Admin/OrderResource.php +++ b/app/Http/Resources/Api/Admin/OrderResource.php @@ -35,25 +35,27 @@ class OrderResource extends JsonResource 'value' => $this->discount->value, ]), 'invoice' => $this->whenLoaded('invoice', fn() => [ - 'id' => $this->invoice->id, - 'order_num' => $this->invoice->order_num, - 'sub_total' => $this->invoice->sub_total, - 'tax' => $this->invoice->tax, - 'total' => $this->invoice->total, - 'status' => $this->invoice->status, - 'date' => $this->invoice->date, - 'due_date' => $this->invoice->due_date, - 'paid_at' => $this->invoice->paid_at, + 'id' => $this->invoice->id, + 'invoice_number' => $this->invoice->invoice_number, + 'sub_total' => $this->invoice->sub_total, + 'discount_amount' => $this->invoice->discount_amount, + 'tax' => $this->invoice->tax, + 'total' => $this->invoice->total, + 'status' => $this->invoice->status, + 'date' => $this->invoice->created_at, + 'due_date' => $this->invoice->due_date, + 'date_paid' => $this->invoice->paid_at, ]), - 'total' => $this->total, - 'payment_method' => $this->whenLoaded('payment', fn() => $this->payment->name), - 'payment_proof' => $this->payment_proof, - 'note' => $this->note, - 'client_ip' => $this->client_ip, - 'status' => $this->status, - 'paid_at' => $this->paid_at, - 'created_at' => $this->created_at, - 'updated_at' => $this->updated_at, + 'sub_total' => $this->sub_total, + 'discount_amount' => $this->discount_amount, + 'total' => $this->total, + 'payment_method' => $this->whenLoaded('payment', fn() => $this->payment->name, $this->payment_method), + 'payment_proof' => $this->payment_proof, + 'note' => $this->note, + 'client_ip' => $this->client_ip, + 'status' => $this->status, + 'created_at' => $this->created_at, + 'updated_at' => $this->updated_at, 'vmwares' => $this->whenLoaded('moduleVmwares', fn() => $this->moduleVmwares->map(fn($vm) => [ 'id' => $vm->id, 'vmware_id' => $vm->vmware_id, diff --git a/app/Listeners/Mail/CheckMailEnabled.php b/app/Listeners/Mail/CheckMailEnabled.php new file mode 100644 index 0000000..c8cdb07 --- /dev/null +++ b/app/Listeners/Mail/CheckMailEnabled.php @@ -0,0 +1,31 @@ +first(); + $config = $setting ? json_decode($setting->value, true) : []; + + if (empty($config['mail_enabled'])) { + Log::info('Mail sending blocked: mail_enabled is off.', [ + 'subject' => $event->message->getSubject(), + ]); + return false; + } + + return true; + } +} diff --git a/app/Mail/InvoiceNotificationMail.php b/app/Mail/InvoiceNotificationMail.php index bddcf5d..c5b970f 100644 --- a/app/Mail/InvoiceNotificationMail.php +++ b/app/Mail/InvoiceNotificationMail.php @@ -7,6 +7,7 @@ use App\Models\Invoice; use App\Services\EmailNotificationTemplateRenderer; use Illuminate\Database\Eloquent\Collection; use Illuminate\Mail\Mailable; +use Illuminate\Mail\Mailables\Attachment; use Illuminate\Mail\Mailables\Content; use Illuminate\Mail\Mailables\Envelope; use Illuminate\Support\Facades\Log; @@ -18,18 +19,26 @@ class InvoiceNotificationMail extends Mailable { public function __construct( public readonly Invoice $invoice, - public readonly Collection $attachments, + public readonly Collection $invoiceAttachments, public readonly string $customerName, public readonly string $message, public readonly string $companyName, + public readonly string $type = 'invoice', + public readonly ?string $invoicePdf = null, + public readonly ?string $invoicePdfFileName = null, + public readonly ?string $paymentUrl = null, ) {} public function envelope(): Envelope { $tpl = $this->activeTemplate(); + $fallbackSubject = $this->type === 'renewal' + ? "Thông báo gia hạn cước #{$this->invoice->invoice_number} - {$this->companyName}" + : "Thông báo hóa đơn #{$this->invoice->invoice_number} - {$this->companyName}"; + $subject = $tpl ? $this->resolveSubject($tpl->subject) - : "Thông báo hóa đơn #{$this->invoice->invoice_number} - {$this->companyName}"; + : $fallbackSubject; return new Envelope(subject: $subject); } @@ -59,16 +68,25 @@ class InvoiceNotificationMail extends Mailable view: 'emails.invoice-notification', with: [ 'invoice' => $this->invoice, - 'attachments' => $this->attachments, + 'attachments' => $this->invoiceAttachments, 'customerName' => $this->customerName, - 'message' => $this->message, + 'bodyMessage' => $this->message, 'companyName' => $this->companyName, + 'type' => $this->type, + 'paymentUrl' => $this->paymentUrl, ] ); } public function attachments(): array { + if ($this->invoicePdf !== null && $this->invoicePdfFileName !== null) { + return [ + Attachment::fromData(fn() => $this->invoicePdf, $this->invoicePdfFileName) + ->withMime('application/pdf'), + ]; + } + return []; } diff --git a/app/Models/Customer.php b/app/Models/Customer.php index 807e2aa..ba2b924 100644 --- a/app/Models/Customer.php +++ b/app/Models/Customer.php @@ -45,6 +45,7 @@ class Customer extends Authenticatable implements JWTSubject 'front_identity_number', 'back_identity_number', 'password', + 'note', 'type', 'credit', 'total', diff --git a/app/Providers/EventServiceProvider.php b/app/Providers/EventServiceProvider.php index 23499eb..6d9b74a 100644 --- a/app/Providers/EventServiceProvider.php +++ b/app/Providers/EventServiceProvider.php @@ -5,7 +5,9 @@ namespace App\Providers; use Illuminate\Auth\Events\Registered; use Illuminate\Auth\Listeners\SendEmailVerificationNotification; use Illuminate\Foundation\Support\Providers\EventServiceProvider as ServiceProvider; +use Illuminate\Mail\Events\MessageSending; use Illuminate\Support\Facades\Event; +use App\Listeners\Mail\CheckMailEnabled; class EventServiceProvider extends ServiceProvider { @@ -18,6 +20,9 @@ class EventServiceProvider extends ServiceProvider Registered::class => [ SendEmailVerificationNotification::class, ], + MessageSending::class => [ + CheckMailEnabled::class, + ], ]; /** diff --git a/app/Repositories/Api/Invoice/InvoiceRepository.php b/app/Repositories/Api/Invoice/InvoiceRepository.php index f5a9615..43a2512 100644 --- a/app/Repositories/Api/Invoice/InvoiceRepository.php +++ b/app/Repositories/Api/Invoice/InvoiceRepository.php @@ -47,7 +47,7 @@ class InvoiceRepository extends BaseApiRepository implements InvoiceRepositoryIn 'tax' => 0, 'total' => $order->total, 'status' => InvoiceStatus::UNPAID->value, - 'due_date' => now()->addMonths($maxMonths)->toDateString(), + 'due_date' => now()->addDays(7)->toDateString(), ]); // Create one invoice_item per order_item line diff --git a/app/Services/Api/Order/OrderQueryService.php b/app/Services/Api/Order/OrderQueryService.php index 1681abe..9f1864c 100644 --- a/app/Services/Api/Order/OrderQueryService.php +++ b/app/Services/Api/Order/OrderQueryService.php @@ -81,6 +81,23 @@ class OrderQueryService ->first(); } + public function findCustomerInvoiceByNumber(int $customerId, string $invoiceNumber): ?Invoice + { + return Invoice::query() + ->with([ + 'order.orderItems.product', + 'order.vmWare.ipAddress', + 'order.vmWare.os', + 'customer', + 'attachments', + 'invoiceItems.product', + 'services.product', + ]) + ->where('customer_id', $customerId) + ->where('invoice_number', $invoiceNumber) + ->first(); + } + public function findCustomerInvoiceByOrderNum(int $customerId, string $orderNumber): ?Order { return Order::query() @@ -168,10 +185,8 @@ class OrderQueryService public function renewService(int $serviceId, int $customerId, int $months): array { - $customer = \App\Models\Customer::find($customerId); - $service = Service::query() - ->with(['orderItem.product']) + ->with(['orderItem.product', 'invoice']) ->where('id', $serviceId) ->where('customer_id', $customerId) ->whereNotIn('status', ['terminated', 'cancelled']) @@ -188,35 +203,60 @@ class OrderQueryService $price = (int) $orderItem->price * $months; - if ($customer->total < $price) { - return ['ok' => false, 'message' => sprintf('Số dư không đủ. Hiện có: %s VNĐ, cần: %s VNĐ.', number_format($customer->total), number_format($price)), 'code' => 400]; + if ($service->invoice_id && Invoice::where('id', $service->invoice_id) + ->where('invoice_type', 2) + ->whereIn('status', [InvoiceStatus::UNPAID->value, InvoiceStatus::OVERDUE->value]) + ->exists()) { + $invoice = Invoice::find($service->invoice_id); + + return [ + 'ok' => true, + 'message' => 'Dịch vụ đã có hóa đơn gia hạn đang chờ thanh toán.', + 'data' => [ + 'order_id' => $invoice?->order_id, + 'invoice_id' => $invoice?->id, + 'invoice_number' => $invoice?->invoice_number, + 'total' => (float) ($invoice?->total ?? 0), + 'status' => $invoice?->status, + 'payment_url' => url('/client/invoices/' . $invoice?->invoice_number . '/payment'), + ], + ]; } - $needsVmStart = $service->vm_id !== null; - - $invoice = DB::transaction(function () use ($customer, $service, $orderItem, $price, $months) { - $customer->total -= $price; - $customer->save(); - - \App\Models\Log::create([ - 'agent' => 'api', + $invoice = DB::transaction(function () use ($service, $orderItem, $price, $months) { + $order = Order::create([ + 'order_num' => OrderHelper::generateOrderNumber(), + 'customer_id' => $service->customer_id, + 'sub_total' => $price, + 'discount_amount' => 0, + 'total' => $price, + 'status' => OrderStatus::PENDING->value, 'client_ip' => request()?->ip(), - 'customer_id' => $customer->id, - 'description' => sprintf('Gia hạn dịch vụ #%s. Số tiền: %s VNĐ. Số dư còn: %s VNĐ.', $service->server_name ?? $service->id, number_format($price), number_format($customer->total)), + 'note' => 'Đơn hàng gia hạn dịch vụ #' . $service->id, + ]); + + OrderItem::create([ + 'order_id' => $order->id, + 'product_id' => $service->product_id, + 'billing_cycle' => $orderItem->billing_cycle, + 'months' => $months, + 'os_id' => $orderItem->os_id, + 'quantity' => 1, + 'server_name' => $service->server_name, + 'price' => $price, ]); $invoice = Invoice::create([ 'invoice_number' => OrderHelper::generateOrderNumber('INV'), - 'customer_id' => $customer->id, - 'order_id' => null, + 'customer_id' => $service->customer_id, + 'order_id' => $order->id, 'invoice_type' => 2, 'sub_total' => $price, 'discount_amount' => 0, 'tax' => 0, 'total' => $price, - 'status' => InvoiceStatus::PAID->value, - 'paid_at' => now(), - 'payment_method' => 'credit', + 'status' => InvoiceStatus::UNPAID->value, + 'due_date' => now()->addDays(7)->toDateString(), ]); \App\Models\InvoiceItem::create([ @@ -230,30 +270,21 @@ class OrderQueryService 'total_price' => $price, ]); - $baseDate = $service->expires_at && $service->expires_at->isFuture() ? $service->expires_at : now(); - $expiresAt = Carbon::parse($baseDate)->addMonths($months); - - $service->update([ - 'status' => 'active', - 'expires_at' => $expiresAt, - 'suspended_at' => null, - 'invoice_id' => $invoice->id, - ]); + $service->update(['invoice_id' => $invoice->id]); return $invoice; }); - if ($needsVmStart) { - dispatch(new VMJobs((int) $service->vm_id, 'Start', false))->delay(now()->addSeconds(5)); - } - return [ 'ok' => true, - 'message' => 'Gia hạn thành công. Dịch vụ đã được kích hoạt lại.', + 'message' => 'Đã tạo hóa đơn gia hạn. Vui lòng thanh toán để gia hạn dịch vụ.', 'data' => [ + 'order_id' => $invoice->order_id, + 'invoice_id' => $invoice->id, 'invoice_number' => $invoice->invoice_number, 'total' => (float) $invoice->total, - 'expires_at' => $service->fresh()?->expires_at?->toISOString(), + 'status' => $invoice->status, + 'payment_url' => url('/client/invoices/' . $invoice->invoice_number . '/payment'), ], ]; } diff --git a/app/Services/Api/Order/OrderService.php b/app/Services/Api/Order/OrderService.php index 345bb5a..89c23e4 100644 --- a/app/Services/Api/Order/OrderService.php +++ b/app/Services/Api/Order/OrderService.php @@ -15,6 +15,7 @@ use App\Models\ServiceAddon; use App\Repositories\Api\Invoice\InvoiceRepository; use App\Repositories\Api\Order\OrderRepository; use App\Repositories\Api\Service\ServiceRepository; +use App\Services\InvoicePaymentService; use Exception; use Illuminate\Support\Facades\DB; use Illuminate\Support\Facades\Log; @@ -25,6 +26,7 @@ class OrderService protected OrderRepository $orderRepo, protected ServiceRepository $productRepo, protected InvoiceRepository $invoiceRepo, + protected InvoicePaymentService $invoicePaymentService, ) {} // ========================================================================= @@ -123,35 +125,18 @@ class OrderService )); } - // Debit balance - $customer->total -= $order->total; - $customer->save(); - - CustomerActivityLog::create([ - 'agent' => 'api', - 'client_ip' => request()?->ip(), - 'customer_id' => $customer->id, - '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) - ), - ]); - // Allow re-paying a cancelled order if ($order->status === OrderStatus::CANCELLED->value) { $order->update(['status' => OrderStatus::PENDING->value]); } - // Mark order → ACTIVE, invoice → PAID - $processedOrder = $this->orderRepo->processPayment($order, $method); - - // Provision a Service for each order item - foreach ($processedOrder->orderItems as $item) { - $this->provisionService($processedOrder, $item, $customer); + if (! $order->invoice) { + throw new Exception('Không tìm thấy hóa đơn của đơn hàng.'); } + $this->invoicePaymentService->payWithBalance($order->invoice, $customer, $method); + $processedOrder = $order->fresh(['orderItems', 'invoice', 'customer']); + $this->dispatchAdminNotification($processedOrder, $customer); return $processedOrder; diff --git a/app/Services/InvoicePaymentService.php b/app/Services/InvoicePaymentService.php new file mode 100644 index 0000000..3179765 --- /dev/null +++ b/app/Services/InvoicePaymentService.php @@ -0,0 +1,227 @@ +customer_id !== (int) $customer->id) { + throw new \InvalidArgumentException('Hóa đơn không thuộc tài khoản này.'); + } + + if ((int) $invoice->status === InvoiceStatus::PAID->value) { + return $invoice->fresh(['order.orderItems.product', 'services', 'customer']); + } + + if (! in_array((int) $invoice->status, [InvoiceStatus::UNPAID->value, InvoiceStatus::OVERDUE->value], true)) { + throw new \InvalidArgumentException('Hóa đơn không ở trạng thái có thể thanh toán.'); + } + + if ((int) $customer->total < (int) $invoice->total) { + throw new \InvalidArgumentException(sprintf( + 'Số dư không đủ. Hiện có: %s VNĐ, cần: %s VNĐ.', + number_format((int) $customer->total), + number_format((int) $invoice->total) + )); + } + + return DB::transaction(function () use ($invoice, $customer, $method) { + $customer->decrement('total', (int) $invoice->total); + + $invoice->update([ + 'status' => InvoiceStatus::PAID->value, + 'paid_at' => now(), + 'payment_method' => $method, + ]); + + $this->recordTransaction($invoice, $method, 'Khách hàng thanh toán hóa đơn #' . $invoice->invoice_number); + + CustomerActivityLog::create([ + 'agent' => 'api', + 'client_ip' => request()?->ip(), + 'customer_id' => $customer->id, + 'description' => sprintf( + 'Thanh toán hóa đơn #%s. Số tiền: %s VNĐ. Số dư còn: %s VNĐ.', + $invoice->invoice_number, + number_format((int) $invoice->total), + number_format((int) $customer->fresh()->total) + ), + ]); + + if ((int) $invoice->invoice_type === 2) { + $this->activateRenewal($invoice); + } elseif ($invoice->order) { + $this->activateOrder($invoice); + } + + return $invoice->fresh(['order.orderItems.product', 'services', 'customer']); + }); + } + + public function markPaidByAdmin(Invoice $invoice, ?int $adminId = null, string $method = 'admin'): Invoice + { + if ((int) $invoice->status === InvoiceStatus::PAID->value) { + return $invoice->fresh(['order.orderItems.product', 'services', 'customer']); + } + + return DB::transaction(function () use ($invoice, $adminId, $method) { + $invoice->update([ + 'status' => InvoiceStatus::PAID->value, + 'paid_at' => now(), + 'payment_method' => $invoice->payment_method ?: $method, + ]); + + $this->recordTransaction($invoice, $method, 'Admin xác nhận thanh toán hóa đơn #' . $invoice->invoice_number, $adminId); + + if ((int) $invoice->invoice_type === 2) { + $this->activateRenewal($invoice); + } elseif ($invoice->order) { + $this->activateOrder($invoice); + } + + return $invoice->fresh(['order.orderItems.product', 'services', 'customer']); + }); + } + + public function recordTransaction(Invoice $invoice, string $method, string $note, ?int $adminId = null): Transaction + { + return Transaction::firstOrCreate( + [ + 'customer_id' => $invoice->customer_id, + 'trans' => $invoice->invoice_number, + 'type' => 'invoice', + ], + [ + 'uuid' => (string) Str::uuid(), + 'public_code' => 'INV-PAY-' . now()->format('Ymd') . '-' . strtoupper(Str::random(6)), + 'amount' => (int) $invoice->total, + 'user_id' => $adminId, + 'status' => TransactionStatus::COMPLETED->value, + 'note' => $note . ' (' . $method . ')', + ] + ); + } + + private function activateOrder(Invoice $invoice): void + { + $order = $invoice->order?->loadMissing(['orderItems.product', 'customer', 'invoice']); + if (! $order) { + return; + } + + if ((int) $order->status !== OrderStatus::ACTIVE->value) { + $order->update([ + 'status' => OrderStatus::ACTIVE->value, + 'payment_method' => $invoice->payment_method, + ]); + } + + foreach ($order->orderItems as $item) { + $service = Service::firstOrCreate( + ['order_item_id' => $item->id], + [ + 'customer_id' => $order->customer_id, + 'product_id' => $item->product_id, + 'invoice_id' => $invoice->id, + 'server_name' => $item->server_name, + 'status' => $item->os_id ? 'pending' : 'active', + 'auto_renew' => false, + 'started_at' => now(), + 'expires_at' => now()->addMonths($item->months ?: $this->cycleToMonths($item->billing_cycle)), + ] + ); + + $this->attachServiceAddons($service, $item); + + if ($service->wasRecentlyCreated && $item->product_id && $item->os_id) { + dispatch(new CreateVirtualMachineJob($item)); + } + } + } + + private function activateRenewal(Invoice $invoice): void + { + $service = Service::query() + ->with(['orderItem.product']) + ->where('invoice_id', $invoice->id) + ->first(); + + if (! $service) { + return; + } + + if ($invoice->order && (int) $invoice->order->status !== OrderStatus::ACTIVE->value) { + $invoice->order->update([ + 'status' => OrderStatus::ACTIVE->value, + 'payment_method' => $invoice->payment_method, + ]); + } + + $months = (int) ($invoice->invoiceItems()->where('type', 'renewal')->value('months') ?: $service->orderItem?->months ?: 1); + $baseDate = $service->expires_at && $service->expires_at->isFuture() + ? $service->expires_at + : now(); + $expiresAt = Carbon::parse($baseDate)->addMonths($months); + + $service->update([ + 'status' => 'active', + 'expires_at' => $expiresAt, + 'suspended_at' => null, + ]); + + if ($service->vm_id) { + dispatch(new VMJobs((int) $service->vm_id, 'Start', false))->delay(now()->addSeconds(5)); + } + } + + private function attachServiceAddons(Service $service, OrderItem $item): void + { + if ($service->addons()->exists()) { + return; + } + + $product = $item->product; + if ($product) { + foreach (['cpu' => $product->cpu, 'ram' => $product->ram, 'disk' => $product->disk, 'bandwidth' => $product->bandwidth] as $type => $value) { + if ($value !== null && $value !== '') { + ServiceAddon::create([ + 'service_id' => $service->id, + 'addon_type' => $type, + 'addon_value' => (string) $value, + 'price' => 0, + ]); + } + } + } + } + + 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/Services/OrderService.php b/app/Services/OrderService.php index 439cba4..df2d9a6 100644 --- a/app/Services/OrderService.php +++ b/app/Services/OrderService.php @@ -2,8 +2,11 @@ namespace App\Services; +use App\Enums\Invoice\InvoiceStatus; +use App\Enums\Order\OrderStatus; use App\Enums\Product\ProductBillingCycle; use App\Enums\Product\ProductTypeEnums; +use App\Enums\TransactionStatus; use App\Http\Helpers\Helper; use App\Jobs\Hosting\CreateHostingJobs; use App\Jobs\Mail\Customer\OrderCreatedJob; @@ -12,6 +15,7 @@ use App\Models\Customer; use App\Models\Order; use App\Models\OrderItem; use App\Models\Payment; +use App\Models\Transaction; use App\Repositories\Invoice\InvoiceRepositoryInterface; use App\Repositories\InvoiceItem\InvoiceItemRepositoryInterface; use App\Repositories\Module\Hosting\HostingRepositoryInterface as ModuleHostingRepositoryInterface; @@ -20,6 +24,7 @@ use App\Repositories\Order\OrderRepositoryInterface; use Carbon\Carbon; use Illuminate\Support\Facades\Auth; use Illuminate\Support\Facades\DB; +use Illuminate\Support\Str; class OrderService { @@ -41,8 +46,13 @@ class OrderService $paymentRecord = Payment::find((int) $data['payment_method']); $isBalancePayment = $paymentRecord && (int) $paymentRecord->type === 0; - // Balance payment → auto-confirm order & invoice - $effectiveStatus = $isBalancePayment ? 1 : (int) $data['status']; + // Balance payment → auto-confirm order, otherwise use requested order status. + $effectiveOrderStatus = $isBalancePayment ? OrderStatus::ACTIVE->value : (int) $data['status']; + $effectiveInvoiceStatus = match ($effectiveOrderStatus) { + OrderStatus::ACTIVE->value => InvoiceStatus::PAID->value, + OrderStatus::CANCELLED->value => InvoiceStatus::CANCELLED->value, + default => InvoiceStatus::UNPAID->value, + }; [$productItems, $relIdData, $total] = $this->buildProductItems($data); @@ -50,14 +60,14 @@ class OrderService $invoiceData = [ 'invoice_number' => Helper::generateUniqueCode(), - 'due_date' => Carbon::today()->toDateString(), - 'paid_at' => $effectiveStatus === 1 ? Carbon::now() : null, + 'due_date' => Carbon::today()->addDays(7)->toDateString(), + 'paid_at' => $effectiveInvoiceStatus === InvoiceStatus::PAID->value ? Carbon::now() : null, 'sub_total' => $total, 'tax' => $total * (10 / 100), 'total' => $orderTotal, 'payment_method' => $paymentRecord?->id, 'customer_id' => $data['customer_id'], - 'status' => $effectiveStatus, + 'status' => $effectiveInvoiceStatus, ]; $orderData = [ @@ -69,11 +79,11 @@ class OrderService 'payment_method' => $paymentRecord?->code ?? null, 'payment_proof' => $data['payment_proof'] ?? null, 'user_id' => Auth::guard('admin')->id(), - 'status' => $effectiveStatus, + 'status' => $effectiveOrderStatus, 'client_ip' => request()->getClientIp(), ]; - $order = DB::transaction(function () use ($invoiceData, $orderData, $productItems, $relIdData, $data, $orderTotal, $isBalancePayment) { + $order = DB::transaction(function () use ($invoiceData, $orderData, $productItems, $relIdData, $data, $orderTotal, $isBalancePayment, $effectiveInvoiceStatus) { $invoice = $this->invoiceRepository->create($invoiceData); $productItems = array_map(fn ($item) => array_merge($item, ['invoice_id' => $invoice->id]), $productItems); @@ -136,6 +146,24 @@ class OrderService $customer->decrement('total', $orderTotal); } + if ($effectiveInvoiceStatus === InvoiceStatus::PAID->value) { + Transaction::firstOrCreate( + [ + 'customer_id' => $data['customer_id'], + 'trans' => $invoice->invoice_number, + 'type' => 'invoice', + ], + [ + 'uuid' => (string) Str::uuid(), + 'public_code' => 'INV-PAY-' . now()->format('Ymd') . '-' . strtoupper(Str::random(6)), + 'amount' => $orderTotal, + 'user_id' => Auth::guard('admin')->id(), + 'status' => TransactionStatus::COMPLETED->value, + 'note' => 'Admin tạo đơn hàng và xác nhận thanh toán hóa đơn #' . $invoice->invoice_number, + ] + ); + } + return $order; }); diff --git a/composer.lock b/composer.lock index 03d998e..2e21c96 100644 --- a/composer.lock +++ b/composer.lock @@ -4,7 +4,7 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "d5aeb450e4244c106f7fd216369e09f3", + "content-hash": "cfc535c27971a90c45e31a2ddd47afe3", "packages": [ { "name": "barryvdh/laravel-dompdf", @@ -921,20 +921,20 @@ }, { "name": "dragonmantank/cron-expression", - "version": "v3.6.0", + "version": "v3.5.0", "source": { "type": "git", "url": "https://github.com/dragonmantank/cron-expression.git", - "reference": "d61a8a9604ec1f8c3d150d09db6ce98b32675013" + "reference": "1b2de7f4a468165dca07b142240733a1973e766d" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/dragonmantank/cron-expression/zipball/d61a8a9604ec1f8c3d150d09db6ce98b32675013", - "reference": "d61a8a9604ec1f8c3d150d09db6ce98b32675013", + "url": "https://api.github.com/repos/dragonmantank/cron-expression/zipball/1b2de7f4a468165dca07b142240733a1973e766d", + "reference": "1b2de7f4a468165dca07b142240733a1973e766d", "shasum": "" }, "require": { - "php": "^8.2|^8.3|^8.4|^8.5" + "php": "^7.2|^8.0" }, "replace": { "mtdowling/cron-expression": "^1.0" @@ -973,7 +973,7 @@ ], "support": { "issues": "https://github.com/dragonmantank/cron-expression/issues", - "source": "https://github.com/dragonmantank/cron-expression/tree/v3.6.0" + "source": "https://github.com/dragonmantank/cron-expression/tree/v3.5.0" }, "funding": [ { @@ -981,7 +981,7 @@ "type": "github" } ], - "time": "2025-10-31T18:51:33+00:00" + "time": "2025-10-31T18:36:32+00:00" }, { "name": "egulias/email-validator", @@ -2255,38 +2255,38 @@ }, { "name": "lcobucci/jwt", - "version": "5.6.0", + "version": "5.3.0", "source": { "type": "git", "url": "https://github.com/lcobucci/jwt.git", - "reference": "bb3e9f21e4196e8afc41def81ef649c164bca25e" + "reference": "08071d8d2c7f4b00222cc4b1fb6aa46990a80f83" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/lcobucci/jwt/zipball/bb3e9f21e4196e8afc41def81ef649c164bca25e", - "reference": "bb3e9f21e4196e8afc41def81ef649c164bca25e", + "url": "https://api.github.com/repos/lcobucci/jwt/zipball/08071d8d2c7f4b00222cc4b1fb6aa46990a80f83", + "reference": "08071d8d2c7f4b00222cc4b1fb6aa46990a80f83", "shasum": "" }, "require": { "ext-openssl": "*", "ext-sodium": "*", - "php": "~8.2.0 || ~8.3.0 || ~8.4.0 || ~8.5.0", + "php": "~8.1.0 || ~8.2.0 || ~8.3.0", "psr/clock": "^1.0" }, "require-dev": { - "infection/infection": "^0.29", - "lcobucci/clock": "^3.2", + "infection/infection": "^0.27.0", + "lcobucci/clock": "^3.0", "lcobucci/coding-standard": "^11.0", - "phpbench/phpbench": "^1.2", + "phpbench/phpbench": "^1.2.9", "phpstan/extension-installer": "^1.2", "phpstan/phpstan": "^1.10.7", "phpstan/phpstan-deprecation-rules": "^1.1.3", "phpstan/phpstan-phpunit": "^1.3.10", "phpstan/phpstan-strict-rules": "^1.5.0", - "phpunit/phpunit": "^11.1" + "phpunit/phpunit": "^10.2.6" }, "suggest": { - "lcobucci/clock": ">= 3.2" + "lcobucci/clock": ">= 3.0" }, "type": "library", "autoload": { @@ -2312,7 +2312,7 @@ ], "support": { "issues": "https://github.com/lcobucci/jwt/issues", - "source": "https://github.com/lcobucci/jwt/tree/5.6.0" + "source": "https://github.com/lcobucci/jwt/tree/5.3.0" }, "funding": [ { @@ -2324,7 +2324,7 @@ "type": "patreon" } ], - "time": "2025-10-17T11:30:53+00:00" + "time": "2024-04-11T23:07:54+00:00" }, { "name": "league/commonmark", @@ -2786,32 +2786,31 @@ }, { "name": "maennchen/zipstream-php", - "version": "3.1.2", + "version": "3.1.1", "source": { "type": "git", "url": "https://github.com/maennchen/ZipStream-PHP.git", - "reference": "aeadcf5c412332eb426c0f9b4485f6accba2a99f" + "reference": "6187e9cc4493da94b9b63eb2315821552015fca9" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/maennchen/ZipStream-PHP/zipball/aeadcf5c412332eb426c0f9b4485f6accba2a99f", - "reference": "aeadcf5c412332eb426c0f9b4485f6accba2a99f", + "url": "https://api.github.com/repos/maennchen/ZipStream-PHP/zipball/6187e9cc4493da94b9b63eb2315821552015fca9", + "reference": "6187e9cc4493da94b9b63eb2315821552015fca9", "shasum": "" }, "require": { "ext-mbstring": "*", "ext-zlib": "*", - "php-64bit": "^8.2" + "php-64bit": "^8.1" }, "require-dev": { - "brianium/paratest": "^7.7", "ext-zip": "*", "friendsofphp/php-cs-fixer": "^3.16", "guzzlehttp/guzzle": "^7.5", "mikey179/vfsstream": "^1.6", "php-coveralls/php-coveralls": "^2.5", - "phpunit/phpunit": "^11.0", - "vimeo/psalm": "^6.0" + "phpunit/phpunit": "^10.0", + "vimeo/psalm": "^5.0" }, "suggest": { "guzzlehttp/psr7": "^2.4", @@ -2852,7 +2851,7 @@ ], "support": { "issues": "https://github.com/maennchen/ZipStream-PHP/issues", - "source": "https://github.com/maennchen/ZipStream-PHP/tree/3.1.2" + "source": "https://github.com/maennchen/ZipStream-PHP/tree/3.1.1" }, "funding": [ { @@ -2860,7 +2859,7 @@ "type": "github" } ], - "time": "2025-01-27T12:07:53+00:00" + "time": "2024-10-10T12:33:01+00:00" }, { "name": "markbaker/complex", @@ -3315,20 +3314,20 @@ }, { "name": "nette/utils", - "version": "v4.1.4", + "version": "v4.0.10", "source": { "type": "git", "url": "https://github.com/nette/utils.git", - "reference": "7da6c396d7ebe142bc857c20479d5e70a5e1aac7" + "reference": "2778deb6aab136c8db4ed1f4d6e9f465ca2dbee3" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/nette/utils/zipball/7da6c396d7ebe142bc857c20479d5e70a5e1aac7", - "reference": "7da6c396d7ebe142bc857c20479d5e70a5e1aac7", + "url": "https://api.github.com/repos/nette/utils/zipball/2778deb6aab136c8db4ed1f4d6e9f465ca2dbee3", + "reference": "2778deb6aab136c8db4ed1f4d6e9f465ca2dbee3", "shasum": "" }, "require": { - "php": "8.2 - 8.5" + "php": "8.0 - 8.5" }, "conflict": { "nette/finder": "<3", @@ -3336,10 +3335,8 @@ }, "require-dev": { "jetbrains/phpstorm-attributes": "^1.2", - "nette/phpstan-rules": "^1.0", "nette/tester": "^2.5", - "phpstan/extension-installer": "^1.4@stable", - "phpstan/phpstan": "^2.1@stable", + "phpstan/phpstan-nette": "^2.0@stable", "tracy/tracy": "^2.9" }, "suggest": { @@ -3353,7 +3350,7 @@ "type": "library", "extra": { "branch-alias": { - "dev-master": "4.1-dev" + "dev-master": "4.0-dev" } }, "autoload": { @@ -3400,9 +3397,9 @@ ], "support": { "issues": "https://github.com/nette/utils/issues", - "source": "https://github.com/nette/utils/tree/v4.1.4" + "source": "https://github.com/nette/utils/tree/v4.0.10" }, - "time": "2026-05-11T20:49:54+00:00" + "time": "2025-12-01T17:30:42+00:00" }, { "name": "nikic/php-parser", @@ -5369,20 +5366,20 @@ }, { "name": "symfony/css-selector", - "version": "v7.4.9", + "version": "v6.4.34", "source": { "type": "git", "url": "https://github.com/symfony/css-selector.git", - "reference": "b75663ed96cf4756e28e3105476f220f92886cc4" + "reference": "b0314c186f1464de048cce58979ff1625ca88bbb" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/css-selector/zipball/b75663ed96cf4756e28e3105476f220f92886cc4", - "reference": "b75663ed96cf4756e28e3105476f220f92886cc4", + "url": "https://api.github.com/repos/symfony/css-selector/zipball/b0314c186f1464de048cce58979ff1625ca88bbb", + "reference": "b0314c186f1464de048cce58979ff1625ca88bbb", "shasum": "" }, "require": { - "php": ">=8.2" + "php": ">=8.1" }, "type": "library", "autoload": { @@ -5414,7 +5411,7 @@ "description": "Converts CSS selectors to XPath expressions", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/css-selector/tree/v7.4.9" + "source": "https://github.com/symfony/css-selector/tree/v6.4.34" }, "funding": [ { @@ -5434,7 +5431,7 @@ "type": "tidelift" } ], - "time": "2026-04-18T13:18:21+00:00" + "time": "2026-02-16T08:37:21+00:00" }, { "name": "symfony/deprecation-contracts", @@ -5588,24 +5585,24 @@ }, { "name": "symfony/event-dispatcher", - "version": "v7.4.14", + "version": "v6.4.37", "source": { "type": "git", "url": "https://github.com/symfony/event-dispatcher.git", - "reference": "51fe3d170227be8d1772214b82ae506e15ed78ff" + "reference": "2e3bf817ba9347341ab15926700fb6320367c0e1" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/event-dispatcher/zipball/51fe3d170227be8d1772214b82ae506e15ed78ff", - "reference": "51fe3d170227be8d1772214b82ae506e15ed78ff", + "url": "https://api.github.com/repos/symfony/event-dispatcher/zipball/2e3bf817ba9347341ab15926700fb6320367c0e1", + "reference": "2e3bf817ba9347341ab15926700fb6320367c0e1", "shasum": "" }, "require": { - "php": ">=8.2", + "php": ">=8.1", "symfony/event-dispatcher-contracts": "^2.5|^3" }, "conflict": { - "symfony/dependency-injection": "<6.4", + "symfony/dependency-injection": "<5.4", "symfony/service-contracts": "<2.5" }, "provide": { @@ -5614,14 +5611,13 @@ }, "require-dev": { "psr/log": "^1|^2|^3", - "symfony/config": "^6.4|^7.0|^8.0", - "symfony/dependency-injection": "^6.4|^7.0|^8.0", - "symfony/error-handler": "^6.4|^7.0|^8.0", - "symfony/expression-language": "^6.4|^7.0|^8.0", - "symfony/framework-bundle": "^6.4|^7.0|^8.0", - "symfony/http-foundation": "^6.4|^7.0|^8.0", + "symfony/config": "^5.4|^6.0|^7.0", + "symfony/dependency-injection": "^5.4|^6.0|^7.0", + "symfony/error-handler": "^5.4|^6.0|^7.0", + "symfony/expression-language": "^5.4|^6.0|^7.0", + "symfony/http-foundation": "^5.4|^6.0|^7.0", "symfony/service-contracts": "^2.5|^3", - "symfony/stopwatch": "^6.4|^7.0|^8.0" + "symfony/stopwatch": "^5.4|^6.0|^7.0" }, "type": "library", "autoload": { @@ -5649,7 +5645,7 @@ "description": "Provides tools that allow your application components to communicate with each other by dispatching events and listening to them", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/event-dispatcher/tree/v7.4.14" + "source": "https://github.com/symfony/event-dispatcher/tree/v6.4.37" }, "funding": [ { @@ -5669,7 +5665,7 @@ "type": "tidelift" } ], - "time": "2026-06-06T11:10:32+00:00" + "time": "2026-04-13T14:11:12+00:00" }, { "name": "symfony/event-dispatcher-contracts", @@ -7101,23 +7097,22 @@ }, { "name": "symfony/string", - "version": "v7.4.13", + "version": "v6.4.39", "source": { "type": "git", "url": "https://github.com/symfony/string.git", - "reference": "961683010db3b27ec6ebcd7308e6e1ee8fa7ffde" + "reference": "62e3c927de664edadb5bef260987eb047a17a113" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/string/zipball/961683010db3b27ec6ebcd7308e6e1ee8fa7ffde", - "reference": "961683010db3b27ec6ebcd7308e6e1ee8fa7ffde", + "url": "https://api.github.com/repos/symfony/string/zipball/62e3c927de664edadb5bef260987eb047a17a113", + "reference": "62e3c927de664edadb5bef260987eb047a17a113", "shasum": "" }, "require": { - "php": ">=8.2", - "symfony/deprecation-contracts": "^2.5|^3.0", + "php": ">=8.1", "symfony/polyfill-ctype": "~1.8", - "symfony/polyfill-intl-grapheme": "~1.33", + "symfony/polyfill-intl-grapheme": "~1.0", "symfony/polyfill-intl-normalizer": "~1.0", "symfony/polyfill-mbstring": "~1.0" }, @@ -7125,11 +7120,10 @@ "symfony/translation-contracts": "<2.5" }, "require-dev": { - "symfony/emoji": "^7.1|^8.0", - "symfony/http-client": "^6.4|^7.0|^8.0", - "symfony/intl": "^6.4|^7.0|^8.0", + "symfony/http-client": "^5.4|^6.0|^7.0", + "symfony/intl": "^6.2|^7.0", "symfony/translation-contracts": "^2.5|^3.0", - "symfony/var-exporter": "^6.4|^7.0|^8.0" + "symfony/var-exporter": "^5.4|^6.0|^7.0" }, "type": "library", "autoload": { @@ -7168,7 +7162,7 @@ "utf8" ], "support": { - "source": "https://github.com/symfony/string/tree/v7.4.13" + "source": "https://github.com/symfony/string/tree/v6.4.39" }, "funding": [ { @@ -7188,7 +7182,7 @@ "type": "tidelift" } ], - "time": "2026-05-23T15:23:29+00:00" + "time": "2026-05-12T11:44:19+00:00" }, { "name": "symfony/translation", @@ -7539,28 +7533,28 @@ }, { "name": "symfony/yaml", - "version": "v7.4.14", + "version": "v6.4.42", "source": { "type": "git", "url": "https://github.com/symfony/yaml.git", - "reference": "f8f328665ace2370d1e10645b807ba1646dc7dcc" + "reference": "989dfb75049b77884f3f8cf9e99f103c8f91ca1c" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/yaml/zipball/f8f328665ace2370d1e10645b807ba1646dc7dcc", - "reference": "f8f328665ace2370d1e10645b807ba1646dc7dcc", + "url": "https://api.github.com/repos/symfony/yaml/zipball/989dfb75049b77884f3f8cf9e99f103c8f91ca1c", + "reference": "989dfb75049b77884f3f8cf9e99f103c8f91ca1c", "shasum": "" }, "require": { - "php": ">=8.2", + "php": ">=8.1", "symfony/deprecation-contracts": "^2.5|^3", "symfony/polyfill-ctype": "^1.8" }, "conflict": { - "symfony/console": "<6.4" + "symfony/console": "<5.4" }, "require-dev": { - "symfony/console": "^6.4|^7.0|^8.0" + "symfony/console": "^5.4|^6.0|^7.0" }, "bin": [ "Resources/bin/yaml-lint" @@ -7591,7 +7585,7 @@ "description": "Loads and dumps YAML files", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/yaml/tree/v7.4.14" + "source": "https://github.com/symfony/yaml/tree/v6.4.42" }, "funding": [ { @@ -7611,7 +7605,7 @@ "type": "tidelift" } ], - "time": "2026-06-08T20:24:16+00:00" + "time": "2026-06-08T07:06:12+00:00" }, { "name": "thecodingmachine/safe", @@ -10692,7 +10686,7 @@ }, "platform-dev": {}, "platform-overrides": { - "php": "8.2.0" + "php": "8.1.0" }, "plugin-api-version": "2.9.0" } diff --git a/database/migrations/2026_07_09_000001_add_note_to_customers_table.php b/database/migrations/2026_07_09_000001_add_note_to_customers_table.php new file mode 100644 index 0000000..b34dfcc --- /dev/null +++ b/database/migrations/2026_07_09_000001_add_note_to_customers_table.php @@ -0,0 +1,26 @@ +text('note')->nullable()->after('password'); + }); + } + } + + public function down(): void + { + if (Schema::hasColumn('customers', 'note')) { + Schema::table('customers', function (Blueprint $table) { + $table->dropColumn('note'); + }); + } + } +}; diff --git a/resources/views/emails/customer/welcome.blade.php b/resources/views/emails/customer/welcome.blade.php index 0792dfa..c9cc65b 100644 --- a/resources/views/emails/customer/welcome.blade.php +++ b/resources/views/emails/customer/welcome.blade.php @@ -25,8 +25,8 @@
🎉
-

Chào mừng bạn đến với {{ $appName }}

-

Tài khoản của bạn đã được tạo thành công

+

Chào mừng Quý Khách đến với Thế Giới Số Portal {{ $appName }}

+

Tài khoản của Quý khách đã được tạo thành công

@@ -34,11 +34,11 @@ Xin chào {{ $fullName }},

- Chào mừng bạn đến với {{ $appName }}! - Tài khoản của bạn đã được khởi tạo thành công và sẵn sàng sử dụng. + Chào mừng Quý Khách đến với Thế Giới Số Portal {{ $appName }}! + Tài khoản của Quý khách đã được khởi tạo thành công và sẵn sàng sử dụng.

- Tại {{ $appName }}, bạn có thể: + Tại {{ $appName }}, Quý khách có thể:

  • Khám phá và đăng ký các dịch vụ Cloud VPS, Hosting, Tên miền
  • @@ -52,8 +52,8 @@

- Cảm ơn bạn đã tin tưởng và đồng hành cùng {{ $appName }}. - Chúc bạn có những trải nghiệm tuyệt vời! + Cảm ơn Quý khách đã tin tưởng và đồng hành cùng {{ $appName }}. + Chúc Quý khách có những trải nghiệm tuyệt vời!

diff --git a/resources/views/emails/invoice-notification.blade.php b/resources/views/emails/invoice-notification.blade.php index 2b7ea6c..eae131c 100644 --- a/resources/views/emails/invoice-notification.blade.php +++ b/resources/views/emails/invoice-notification.blade.php @@ -3,11 +3,11 @@ - Thông báo hóa đơn + {{ ($type ?? 'invoice') === 'renewal' ? 'Thông báo gia hạn cước' : 'Thông báo hóa đơn' }} +@php + $formatMailDate = static function ($value): string { + if (empty($value)) { + return '—'; + } + + if ($value instanceof \Carbon\CarbonInterface) { + return $value->format('d/m/Y'); + } + + if (is_numeric($value)) { + return date('d/m/Y', (int) $value); + } + + try { + return \Carbon\Carbon::parse($value)->format('d/m/Y'); + } catch (\Throwable) { + return '—'; + } + }; +@endphp
-

Thông báo hóa đơn

+

{{ ($type ?? 'invoice') === 'renewal' ? 'Thông báo gia hạn cước' : 'Thông báo hóa đơn' }}

Xin chào {{ $customerName }},

-

{{ $message }}

+

{{ $bodyMessage ?? '' }}

@@ -46,11 +69,11 @@ - + - + @@ -71,6 +94,26 @@
Ngày tạo{{ $invoice->date ? date('d/m/Y', $invoice->date) : '—' }}{{ $formatMailDate($invoice->date) }}
Hạn thanh toán{{ $invoice->due_date ? date('d/m/Y', $invoice->due_date) : '—' }}{{ $formatMailDate($invoice->due_date) }}
Tổng tiền
+
+ @if(($type ?? 'invoice') === 'renewal') + File PDF hóa đơn gia hạn đã được đính kèm trong email này. Vui lòng hoàn tất thanh toán trước hạn để dịch vụ không bị gián đoạn. + @else + File PDF hóa đơn đã được đính kèm trong email này để quý khách tiện lưu trữ và đối chiếu. + @endif +
+ + @if(!empty($paymentUrl)) + +

+ Hoặc sao chép liên kết thanh toán: + {{ $paymentUrl }} +

+ @endif + @if($attachments->isNotEmpty())

File đính kèm

diff --git a/routes/client.php b/routes/client.php index 6743d8a..225577a 100644 --- a/routes/client.php +++ b/routes/client.php @@ -48,10 +48,16 @@ Route::prefix('v1') Route::get('orders/{id}', 'getClientOrderDetail')->name('orders.detail'); Route::post('orders/{id}/retry', 'retryOrderProvisioning')->name('orders.retry'); Route::get('invoices', 'getClientInvoices')->name('invoices.list'); - Route::get('invoices/{id}', 'getClientInvoiceDetail')->name('invoices.detail'); + Route::get('invoices/number/{invoiceNumber}', 'getClientInvoiceDetailByNumber')->name( + 'invoices.detailByNumber', + ); + Route::post('invoices/number/{invoiceNumber}/pay', 'payClientInvoiceByNumber')->name( + 'invoices.payByNumber', + ); Route::get('invoices/order/{orderNum}', 'getClientInvoiceDetailByOrderNum')->name( 'invoices.detailByOrderNum', ); + Route::get('invoices/{id}', 'getClientInvoiceDetail')->name('invoices.detail'); Route::get('dich-vu', 'getClientServices')->name('services.list'); Route::patch('dich-vu/{id}/auto-renew', 'updateClientServiceAutoRenew')->name( 'services.autoRenew.update', @@ -155,6 +161,7 @@ Route::prefix('v1') ->group(function () { Route::controller(SettingController::class)->group(function () { Route::get('/', 'getSetting')->name('get'); + Route::get('email', 'getEmailSetting')->name('email'); }); });