This commit is contained in:
baodev0
2026-07-09 11:19:50 +07:00
parent 64232d9168
commit 61c0e026c8
28 changed files with 939 additions and 230 deletions
Vendored
BIN
View File
Binary file not shown.
@@ -0,0 +1,91 @@
<?php
namespace App\Console\Commands;
use App\Enums\Invoice\InvoiceStatus;
use App\Enums\Order\OrderStatus;
use App\Models\Invoice;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Log;
class CheckOverdueInvoices extends Command
{
protected $signature = 'invoices:check-overdue {--dry-run : Preview invoices without updating data}';
protected $description = 'Mark unpaid invoices past due date as overdue and keep related orders unpaid.';
public function handle(): int
{
$today = now()->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;
}
}
+79 -8
View File
@@ -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}.");
}
}
+6
View File
@@ -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();
}
/**
+4
View File
@@ -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',
};
}
}
@@ -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();
@@ -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']);
@@ -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,
@@ -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);
@@ -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,
],
]);
}
}
@@ -33,6 +33,7 @@ class CreateCustomerRequest extends FormRequest
'postcode' => 'nullable',
'country' => 'nullable',
'email_marketing' => 'nullable',
'note' => 'nullable|string',
];
}
}
@@ -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,
@@ -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,
];
}
}
+20 -18
View File
@@ -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,
+31
View File
@@ -0,0 +1,31 @@
<?php
namespace App\Listeners\Mail;
use App\Models\Setting;
use Illuminate\Mail\Events\MessageSending;
use Illuminate\Support\Facades\Log;
class CheckMailEnabled
{
/**
* Handle the event.
*
* @param \Illuminate\Mail\Events\MessageSending $event
* @return bool|false Return false to cancel sending
*/
public function handle(MessageSending $event): bool
{
$setting = Setting::where('key', 'email_settings')->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;
}
}
+22 -4
View File
@@ -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 [];
}
+1
View File
@@ -45,6 +45,7 @@ class Customer extends Authenticatable implements JWTSubject
'front_identity_number',
'back_identity_number',
'password',
'note',
'type',
'credit',
'total',
+5
View File
@@ -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,
],
];
/**
@@ -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
+66 -35
View File
@@ -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'),
],
];
}
+7 -22
View File
@@ -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;
+227
View File
@@ -0,0 +1,227 @@
<?php
namespace App\Services;
use App\Enums\Invoice\InvoiceStatus;
use App\Enums\Order\OrderStatus;
use App\Enums\TransactionStatus;
use App\Jobs\VirtualMachine\Action\VMJobs;
use App\Jobs\VirtualMachine\Create\CreateVirtualMachineJob;
use App\Models\Customer;
use App\Models\Invoice;
use App\Models\Log as CustomerActivityLog;
use App\Models\OrderItem;
use App\Models\Service;
use App\Models\ServiceAddon;
use App\Models\Transaction;
use Carbon\Carbon;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Str;
class InvoicePaymentService
{
public function payWithBalance(Invoice $invoice, Customer $customer, string $method = 'balance'): Invoice
{
if ((int) $invoice->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,
};
}
}
+35 -7
View File
@@ -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;
});
Generated
+80 -86
View File
@@ -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"
}
@@ -0,0 +1,26 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
if (! Schema::hasColumn('customers', 'note')) {
Schema::table('customers', function (Blueprint $table) {
$table->text('note')->nullable()->after('password');
});
}
}
public function down(): void
{
if (Schema::hasColumn('customers', 'note')) {
Schema::table('customers', function (Blueprint $table) {
$table->dropColumn('note');
});
}
}
};
@@ -25,8 +25,8 @@
<div class="card">
<div class="header">
<div class="icon">🎉</div>
<h1>Chào mừng bạn đến với {{ $appName }}</h1>
<p>Tài khoản của bạn đã được tạo thành công</p>
<h1>Chào mừng Quý Khách đến với Thế Giới Số Portal {{ $appName }}</h1>
<p>Tài khoản của Quý khách đã được tạo thành công</p>
</div>
<div class="body">
@@ -34,11 +34,11 @@
Xin chào <strong>{{ $fullName }}</strong>,
</p>
<p style="margin:0 0 18px 0;">
Chào mừng bạn đến với <strong>{{ $appName }}</strong>!
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 <strong>{{ $appName }}</strong>!
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.
</p>
<p style="margin:0 0 12px 0;">
Tại <span>{{ $appName }}</span>, bạn có thể:
Tại <span>{{ $appName }}</span>, Quý khách có thể:
</p>
<ul class="feature-list">
<li>Khám phá và đăng ký các dịch vụ Cloud VPS, Hosting, Tên miền</li>
@@ -52,8 +52,8 @@
</div>
<p style="margin:0;color:#6b7280;">
Cảm ơn bạn đã tin tưởng và đồng hành cùng <span>{{ $appName }}</span>.
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 <span>{{ $appName }}</span>.
Chúc Quý khách có những trải nghiệm tuyệt vời!
</p>
</div>
@@ -3,11 +3,11 @@
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Thông báo hóa đơn</title>
<title>{{ ($type ?? 'invoice') === 'renewal' ? 'Thông báo gia hạn cước' : 'Thông báo hóa đơn' }}</title>
<style>
body { font-family: Arial, sans-serif; background: #f4f4f4; margin: 0; padding: 0; }
.wrapper { max-width: 640px; margin: 30px auto; background: #fff; border-radius: 8px; overflow: hidden; box-shadow: 0 2px 8px rgba(0,0,0,0.08); }
.header { background: #1a56db; padding: 24px 32px; }
.header { background: #f16723; padding: 24px 32px; }
.header h1 { color: #fff; margin: 0; font-size: 22px; }
.body { padding: 32px; color: #374151; }
.body p { line-height: 1.7; margin: 0 0 12px; }
@@ -23,20 +23,43 @@
.attachments h3 { font-size: 15px; margin-bottom: 10px; color: #374151; }
.attachments ul { padding-left: 16px; margin: 0; }
.attachments ul li { margin-bottom: 6px; font-size: 14px; }
.attachments ul li a { color: #1a56db; text-decoration: none; }
.note-box { background: #fffbeb; border-left: 4px solid #f59e0b; padding: 12px 16px; margin: 20px 0; font-size: 14px; }
.attachments ul li a { color: #f16723; text-decoration: none; }
.note-box { background: #fff7ed; border-left: 4px solid #f16723; padding: 12px 16px; margin: 20px 0; font-size: 14px; }
.pay-button-wrap { text-align: center; margin: 28px 0; }
.pay-button { display: inline-block; background: #f16723; color: #ffffff !important; text-decoration: none; padding: 14px 28px; border-radius: 8px; font-size: 15px; font-weight: 600; }
.footer { background: #f9fafb; border-top: 1px solid #e5e7eb; padding: 20px 32px; text-align: center; font-size: 13px; color: #9ca3af; }
</style>
</head>
<body>
@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
<div class="wrapper">
<div class="header">
<h1>Thông báo hóa đơn</h1>
<h1>{{ ($type ?? 'invoice') === 'renewal' ? 'Thông báo gia hạn cước' : 'Thông báo hóa đơn' }}</h1>
</div>
<div class="body">
<p>Xin chào <strong>{{ $customerName }}</strong>,</p>
<p>{{ $message }}</p>
<p>{{ $bodyMessage ?? '' }}</p>
<div class="invoice-box">
<table>
@@ -46,11 +69,11 @@
</tr>
<tr>
<td>Ngày tạo</td>
<td>{{ $invoice->date ? date('d/m/Y', $invoice->date) : '—' }}</td>
<td>{{ $formatMailDate($invoice->date) }}</td>
</tr>
<tr>
<td>Hạn thanh toán</td>
<td>{{ $invoice->due_date ? date('d/m/Y', $invoice->due_date) : '—' }}</td>
<td>{{ $formatMailDate($invoice->due_date) }}</td>
</tr>
<tr>
<td>Tổng tiền</td>
@@ -71,6 +94,26 @@
</table>
</div>
<div class="note-box">
@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
</div>
@if(!empty($paymentUrl))
<div class="pay-button-wrap">
<a class="pay-button" href="{{ $paymentUrl }}" target="_blank" rel="noopener">
Thanh toán hóa đơn
</a>
</div>
<p style="margin:0 0 18px 0;word-break:break-all;font-size:13px;color:#6b7280;">
Hoặc sao chép liên kết thanh toán:
<a href="{{ $paymentUrl }}" style="color:#f16723;">{{ $paymentUrl }}</a>
</p>
@endif
@if($attachments->isNotEmpty())
<div class="attachments">
<h3>File đính kèm</h3>
+8 -1
View File
@@ -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');
});
});