update
This commit is contained in:
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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}.");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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,
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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 [];
|
||||
}
|
||||
|
||||
|
||||
@@ -45,6 +45,7 @@ class Customer extends Authenticatable implements JWTSubject
|
||||
'front_identity_number',
|
||||
'back_identity_number',
|
||||
'password',
|
||||
'note',
|
||||
'type',
|
||||
'credit',
|
||||
'total',
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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'),
|
||||
],
|
||||
];
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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
@@ -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');
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
Reference in New Issue
Block a user