update
This commit is contained in:
@@ -1,127 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Api\Admin;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\EmailNotificationTemplate;
|
||||
use App\Services\EmailNotificationTemplateRenderer;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
|
||||
class EmailNotificationTemplateController extends Controller
|
||||
{
|
||||
public function __construct(
|
||||
private readonly EmailNotificationTemplateRenderer $renderer,
|
||||
) {}
|
||||
|
||||
public function index(): JsonResponse
|
||||
{
|
||||
$templates = EmailNotificationTemplate::orderByDesc('is_active')
|
||||
->orderBy('name')
|
||||
->get();
|
||||
|
||||
return response()->json(['error' => false, 'data' => $templates]);
|
||||
}
|
||||
|
||||
public function show(int $id): JsonResponse
|
||||
{
|
||||
$tpl = EmailNotificationTemplate::findOrFail($id);
|
||||
$tpl['block_field_definitions'] = EmailNotificationTemplate::blockFieldDefinitions();
|
||||
return response()->json(['error' => false, 'data' => $tpl]);
|
||||
}
|
||||
|
||||
public function store(Request $request): JsonResponse
|
||||
{
|
||||
$validated = $request->validate([
|
||||
'name' => 'required|string|max:255',
|
||||
'type' => 'required|string|in:invoice_notification,vm_credentials',
|
||||
'subject' => 'required|string|max:500',
|
||||
'header_color' => 'nullable|string|max:20',
|
||||
'header_title' => 'nullable|string|max:255',
|
||||
'blocks' => 'nullable|array',
|
||||
'is_active' => 'boolean',
|
||||
]);
|
||||
|
||||
// Use default blocks if none provided
|
||||
if (empty($validated['blocks'])) {
|
||||
$validated['blocks'] = EmailNotificationTemplate::defaultBlocks($validated['type']);
|
||||
}
|
||||
|
||||
// If activating this template, deactivate others of the same type
|
||||
if ($validated['is_active'] ?? true) {
|
||||
EmailNotificationTemplate::where('type', $validated['type'])->update(['is_active' => false]);
|
||||
}
|
||||
|
||||
$tpl = EmailNotificationTemplate::create($validated);
|
||||
|
||||
return response()->json(['error' => false, 'data' => $tpl], 201);
|
||||
}
|
||||
|
||||
public function update(Request $request, int $id): JsonResponse
|
||||
{
|
||||
$tpl = EmailNotificationTemplate::findOrFail($id);
|
||||
|
||||
$validated = $request->validate([
|
||||
'name' => 'sometimes|string|max:255',
|
||||
'subject' => 'sometimes|string|max:500',
|
||||
'header_color' => 'nullable|string|max:20',
|
||||
'header_title' => 'nullable|string|max:255',
|
||||
'blocks' => 'nullable|array',
|
||||
'is_active' => 'boolean',
|
||||
]);
|
||||
|
||||
// If activating this template, deactivate others of the same type
|
||||
if (!empty($validated['is_active'])) {
|
||||
EmailNotificationTemplate::where('type', $tpl->type)
|
||||
->where('id', '!=', $id)
|
||||
->update(['is_active' => false]);
|
||||
}
|
||||
|
||||
$tpl->update($validated);
|
||||
|
||||
return response()->json(['error' => false, 'data' => $tpl->fresh()]);
|
||||
}
|
||||
|
||||
public function destroy(int $id): JsonResponse
|
||||
{
|
||||
EmailNotificationTemplate::findOrFail($id)->delete();
|
||||
return response()->json(['error' => false, 'message' => 'Đã xoá template.']);
|
||||
}
|
||||
|
||||
public function preview(int $id): JsonResponse
|
||||
{
|
||||
$tpl = EmailNotificationTemplate::findOrFail($id);
|
||||
$html = $this->renderer->renderPreview($tpl);
|
||||
return response()->json(['error' => false, 'html' => $html]);
|
||||
}
|
||||
|
||||
public function fieldDefinitions(): JsonResponse
|
||||
{
|
||||
return response()->json([
|
||||
'error' => false,
|
||||
'data' => [
|
||||
'block_types' => $this->blockTypeDefinitions(),
|
||||
'block_field_definitions' => EmailNotificationTemplate::blockFieldDefinitions(),
|
||||
'default_blocks' => EmailNotificationTemplate::defaultBlocks(),
|
||||
],
|
||||
]);
|
||||
}
|
||||
|
||||
private function blockTypeDefinitions(): array
|
||||
{
|
||||
return [
|
||||
// Dùng cho cả 2 loại
|
||||
['type' => 'greeting', 'label' => 'Lời chào', 'description' => 'Chào hỏi khách hàng theo tên', 'for' => ['invoice_notification', 'vm_credentials']],
|
||||
['type' => 'custom_text', 'label' => 'Văn bản tùy chỉnh', 'description' => 'Nội dung HTML hoặc text tự do', 'for' => ['invoice_notification', 'vm_credentials']],
|
||||
['type' => 'footer', 'label' => 'Chữ ký kết thúc', 'description' => 'Trân trọng + tên công ty', 'for' => ['invoice_notification', 'vm_credentials']],
|
||||
// invoice_notification
|
||||
['type' => 'invoice_info', 'label' => 'Thông tin hóa đơn', 'description' => 'Số HĐ, ngày tạo, tổng tiền, trạng thái...', 'for' => ['invoice_notification']],
|
||||
['type' => 'customer_info', 'label' => 'Thông tin khách hàng', 'description' => 'Tên, email, địa chỉ khách hàng', 'for' => ['invoice_notification']],
|
||||
['type' => 'order_items', 'label' => 'Chi tiết sản phẩm', 'description' => 'Bảng sản phẩm từ đơn hàng', 'for' => ['invoice_notification']],
|
||||
['type' => 'attachments', 'label' => 'File đính kèm', 'description' => 'Danh sách file đính kèm hóa đơn', 'for' => ['invoice_notification']],
|
||||
// vm_credentials
|
||||
['type' => 'vm_credentials', 'label' => 'Thông tin truy cập VPS', 'description' => 'IP, username, port, password, OS', 'for' => ['vm_credentials']],
|
||||
['type' => 'product_info', 'label' => 'Thông tin dịch vụ', 'description' => 'Tên sản phẩm, chu kỳ, giá, hạn dùng', 'for' => ['vm_credentials']],
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -11,6 +11,7 @@ use App\Services\Email\EmailTemplateRenderer;
|
||||
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\Validation\Rule;
|
||||
|
||||
@@ -105,6 +106,7 @@ class EmailTemplateController extends Controller
|
||||
'invoice_template_id' => 'nullable|integer|exists:template,id',
|
||||
'subject' => 'nullable|string|max:255',
|
||||
'message' => 'nullable|string',
|
||||
'builder_data' => 'nullable|array',
|
||||
'status' => 'boolean',
|
||||
'mail_config' => 'nullable|array',
|
||||
'mail_config.from_email' => 'nullable|email',
|
||||
@@ -126,6 +128,7 @@ class EmailTemplateController extends Controller
|
||||
'invoice_template_id' => $data['invoice_template_id'] ?? null,
|
||||
'subject' => $data['subject'] ?? null,
|
||||
'message' => $data['message'] ?? null,
|
||||
'builder_data' => $data['builder_data'] ?? null,
|
||||
'status' => $data['status'] ?? 1,
|
||||
'metadata' => isset($data['mail_config']) ? ['mail' => $data['mail_config']] : null,
|
||||
]));
|
||||
@@ -148,6 +151,7 @@ class EmailTemplateController extends Controller
|
||||
'invoice_template_id' => 'sometimes|nullable|integer|exists:template,id',
|
||||
'subject' => 'sometimes|nullable|string|max:255',
|
||||
'message' => 'sometimes|nullable|string',
|
||||
'builder_data' => 'sometimes|nullable|array',
|
||||
'status' => 'sometimes|boolean',
|
||||
'mail_config' => 'sometimes|nullable|array',
|
||||
'mail_config.from_email' => 'nullable|email',
|
||||
@@ -295,6 +299,12 @@ class EmailTemplateController extends Controller
|
||||
} catch (InvalidTemplateVariableException $e) {
|
||||
return response()->json(['error' => true, 'message' => $e->getMessage()], 422);
|
||||
} catch (\Throwable $e) {
|
||||
Log::error('Gửi email test thất bại', [
|
||||
'template_id' => $id,
|
||||
'to' => $data['to'],
|
||||
'exception' => $e,
|
||||
]);
|
||||
|
||||
return response()->json(['error' => true, 'message' => 'Không thể gửi email: ' . $e->getMessage()], 500);
|
||||
}
|
||||
|
||||
@@ -349,6 +359,7 @@ class EmailTemplateController extends Controller
|
||||
'invoice_template_name' => $t->invoiceTemplate?->name,
|
||||
'subject' => $t->subject,
|
||||
'message' => $t->message,
|
||||
'builder_data' => $t->builder_data,
|
||||
'status' => $t->status,
|
||||
'variables' => array_values($t->variables()),
|
||||
'mail_config' => $t->mailConfig(),
|
||||
|
||||
@@ -18,6 +18,7 @@ use App\Repositories\InvoiceAttachment\InvoiceAttachmentRepositoryInterface;
|
||||
use App\Repositories\InvoiceItem\InvoiceItemRepositoryInterface;
|
||||
use App\Services\InvoicePaymentService;
|
||||
use App\Services\InvoicePdfService;
|
||||
use App\Services\InvoiceReminderService;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
@@ -193,6 +194,7 @@ class InvoiceController extends Controller
|
||||
if ($status === InvoiceStatus::PAID->value) {
|
||||
$this->invoicePaymentService->markPaidByAdmin($invoice, auth()->id());
|
||||
} else {
|
||||
$this->assertCancellable($invoice);
|
||||
$invoice->update([
|
||||
'status' => $status,
|
||||
'paid_at' => null,
|
||||
@@ -205,6 +207,12 @@ class InvoiceController extends Controller
|
||||
'error' => false,
|
||||
'message' => 'Invoice updated successfully'
|
||||
]);
|
||||
} catch (\InvalidArgumentException $e) {
|
||||
DB::rollBack();
|
||||
return response()->json([
|
||||
'error' => true,
|
||||
'message' => $e->getMessage()
|
||||
], 422);
|
||||
} catch (\Exception $e) {
|
||||
DB::rollBack();
|
||||
return response()->json([
|
||||
@@ -225,6 +233,15 @@ class InvoiceController extends Controller
|
||||
], 404);
|
||||
}
|
||||
|
||||
// Hoá đơn đã thu tiền là chứng từ gốc: xoá đi thì Transaction và số dư đã trừ vẫn còn,
|
||||
// sổ sách lệch mà không còn dấu vết. Muốn đảo thì phải đi qua luồng hoàn tiền.
|
||||
if (in_array((int) $invoice->status, [InvoiceStatus::PAID->value, InvoiceStatus::REFUNDED->value], true)) {
|
||||
return response()->json([
|
||||
'error' => true,
|
||||
'message' => 'Không thể xoá hoá đơn đã thanh toán hoặc đã hoàn tiền. Hãy dùng chức năng hoàn tiền nếu cần đảo giao dịch.',
|
||||
], 422);
|
||||
}
|
||||
|
||||
try {
|
||||
// Note: `orders` has no `invoice_id` column (the FK runs the other way —
|
||||
// invoices.order_id -> orders.id), so there's nothing to clean up there. Any
|
||||
@@ -244,7 +261,7 @@ class InvoiceController extends Controller
|
||||
}
|
||||
}
|
||||
|
||||
public function markAsPaid(int $id): JsonResponse
|
||||
public function markAsPaid(Request $request, int $id): JsonResponse
|
||||
{
|
||||
$invoice = $this->invoiceRepository->find($id);
|
||||
|
||||
@@ -255,18 +272,42 @@ class InvoiceController extends Controller
|
||||
], 404);
|
||||
}
|
||||
|
||||
// Bỏ trống = thu đủ hoá đơn. Nhập số nhỏ hơn khi khách mới chuyển được một phần —
|
||||
// hoá đơn ghi nhận trả một phần và chưa kích hoạt dịch vụ.
|
||||
$validated = $request->validate([
|
||||
'received_amount' => 'nullable|integer|min:1',
|
||||
]);
|
||||
|
||||
try {
|
||||
DB::beginTransaction();
|
||||
|
||||
$invoice->load(['order', 'customer']);
|
||||
$this->invoicePaymentService->markPaidByAdmin($invoice, auth()->id());
|
||||
$invoice = $this->invoicePaymentService->markPaidByAdmin(
|
||||
$invoice,
|
||||
auth()->id(),
|
||||
receivedAmount: $validated['received_amount'] ?? null,
|
||||
);
|
||||
|
||||
DB::commit();
|
||||
|
||||
$outstanding = InvoicePaymentService::outstanding($invoice);
|
||||
|
||||
return response()->json([
|
||||
'error' => false,
|
||||
'message' => 'Invoice marked as paid'
|
||||
'message' => $outstanding > 0
|
||||
? 'Đã ghi nhận thanh toán một phần. Còn thiếu ' . number_format($outstanding) . ' VNĐ.'
|
||||
: 'Invoice marked as paid',
|
||||
'data' => [
|
||||
'amount_paid' => (int) $invoice->amount_paid,
|
||||
'outstanding' => $outstanding,
|
||||
],
|
||||
]);
|
||||
} catch (\InvalidArgumentException $e) {
|
||||
DB::rollBack();
|
||||
return response()->json([
|
||||
'error' => true,
|
||||
'message' => $e->getMessage()
|
||||
], 422);
|
||||
} catch (\Exception $e) {
|
||||
DB::rollBack();
|
||||
return response()->json([
|
||||
@@ -288,6 +329,8 @@ class InvoiceController extends Controller
|
||||
}
|
||||
|
||||
try {
|
||||
$this->assertCancellable($invoice);
|
||||
|
||||
DB::beginTransaction();
|
||||
$invoice->update([
|
||||
'status' => InvoiceStatus::CANCELLED->value,
|
||||
@@ -299,6 +342,12 @@ class InvoiceController extends Controller
|
||||
'error' => false,
|
||||
'message' => 'Invoice cancelled'
|
||||
]);
|
||||
} catch (\InvalidArgumentException $e) {
|
||||
DB::rollBack();
|
||||
return response()->json([
|
||||
'error' => true,
|
||||
'message' => $e->getMessage()
|
||||
], 422);
|
||||
} catch (\Exception $e) {
|
||||
DB::rollBack();
|
||||
return response()->json([
|
||||
@@ -308,6 +357,98 @@ class InvoiceController extends Controller
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Hoàn tiền hoá đơn đã thu — cộng lại số dư khách và ghi Transaction âm, thay cho việc
|
||||
* huỷ hoá đơn đã thanh toán (nay đã bị chặn) hoặc xử lý tay ngoài hệ thống.
|
||||
*/
|
||||
public function refund(Request $request, int $id): JsonResponse
|
||||
{
|
||||
$invoice = $this->invoiceRepository->find($id);
|
||||
|
||||
if (!$invoice) {
|
||||
return response()->json([
|
||||
'error' => true,
|
||||
'message' => "Can't find invoice by ID #" . $id
|
||||
], 404);
|
||||
}
|
||||
|
||||
$validated = $request->validate([
|
||||
'amount' => 'nullable|integer|min:1',
|
||||
'reason' => 'nullable|string|max:500',
|
||||
]);
|
||||
|
||||
try {
|
||||
$invoice = $this->invoicePaymentService->refund(
|
||||
$invoice,
|
||||
auth()->id(),
|
||||
$validated['amount'] ?? null,
|
||||
$validated['reason'] ?? '',
|
||||
);
|
||||
} catch (\InvalidArgumentException $e) {
|
||||
return response()->json(['error' => true, 'message' => $e->getMessage()], 422);
|
||||
} catch (\Throwable $e) {
|
||||
Log::error('InvoiceController::refund error: ' . $e->getMessage(), ['invoice_id' => $id]);
|
||||
return response()->json(['error' => true, 'message' => 'Không thể hoàn tiền, vui lòng thử lại.'], 500);
|
||||
}
|
||||
|
||||
return response()->json([
|
||||
'error' => false,
|
||||
'message' => 'Đã hoàn tiền vào số dư khách hàng.',
|
||||
'data' => ['invoice' => new InvoiceResource($invoice)],
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Nhắc thanh toán thủ công — dùng khi kế toán muốn thúc riêng một khách mà không chờ
|
||||
* mốc tự động trong billing.reminder_offsets.
|
||||
*/
|
||||
public function remind(int $id, InvoiceReminderService $reminders): JsonResponse
|
||||
{
|
||||
$invoice = $this->invoiceRepository->find($id);
|
||||
|
||||
if (!$invoice) {
|
||||
return response()->json([
|
||||
'error' => true,
|
||||
'message' => "Can't find invoice by ID #" . $id
|
||||
], 404);
|
||||
}
|
||||
|
||||
if (!$reminders->isRemindable($invoice)) {
|
||||
return response()->json([
|
||||
'error' => true,
|
||||
'message' => 'Chỉ nhắc được hoá đơn chưa thanh toán hoặc quá hạn.',
|
||||
], 422);
|
||||
}
|
||||
|
||||
try {
|
||||
$reminders->remind($invoice->load('customer'), force: true);
|
||||
} catch (\Throwable $e) {
|
||||
return response()->json([
|
||||
'error' => true,
|
||||
'message' => 'Không gửi được email nhắc: ' . $e->getMessage(),
|
||||
], 500);
|
||||
}
|
||||
|
||||
return response()->json([
|
||||
'error' => false,
|
||||
'message' => 'Đã gửi email nhắc thanh toán tới ' . $invoice->customer?->email,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Huỷ hoá đơn đã thu tiền sẽ xoá paid_at nhưng không hoàn số dư, không revert order/service,
|
||||
* trong khi Transaction vẫn còn — sổ sách lệch im lặng. Chặn ở đây, đảo giao dịch phải đi
|
||||
* qua luồng hoàn tiền.
|
||||
*/
|
||||
private function assertCancellable(Invoice $invoice): void
|
||||
{
|
||||
if (in_array((int) $invoice->status, [InvoiceStatus::PAID->value, InvoiceStatus::REFUNDED->value], true)) {
|
||||
throw new \InvalidArgumentException(
|
||||
'Không thể huỷ hoá đơn đã thanh toán hoặc đã hoàn tiền. Hãy dùng chức năng hoàn tiền nếu cần đảo giao dịch.'
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Attachments ────────────────────────────────────────────────────────────
|
||||
|
||||
public function listAttachments(int $id): JsonResponse
|
||||
|
||||
@@ -43,6 +43,27 @@ class SettingController extends Controller
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* GET /settings/public
|
||||
* Không cần token — chỉ trả các field công khai dùng cho trang đăng nhập.
|
||||
*/
|
||||
public function public(): JsonResponse
|
||||
{
|
||||
$setting = Setting::where('key', 'setting')->first();
|
||||
$value = $setting ? json_decode((string) $setting->value, true) : [];
|
||||
|
||||
return response()->json([
|
||||
'error' => false,
|
||||
'data' => [
|
||||
'title' => $value['title'] ?? '',
|
||||
'company' => $value['company'] ?? '',
|
||||
'logo' => $value['logo'] ?? '',
|
||||
'favi_icon' => $value['favi_icon'] ?? '',
|
||||
'brand_color' => $value['brand_color'] ?? '',
|
||||
],
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* PUT /administrator/settings
|
||||
* Accepts: { setting: {...}, smtp: {...} }
|
||||
@@ -105,6 +126,43 @@ class SettingController extends Controller
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* GET /administrator/vat-settings
|
||||
* Returns saved VAT rates per service type.
|
||||
*/
|
||||
public function vatIndex(): JsonResponse
|
||||
{
|
||||
$row = Setting::where('key', 'vat_settings')->first();
|
||||
$data = $row ? json_decode((string) $row->value, true) : [];
|
||||
|
||||
return response()->json(['error' => false, 'data' => is_array($data) ? $data : []]);
|
||||
}
|
||||
|
||||
/**
|
||||
* PUT /administrator/vat-settings
|
||||
* Accepts: { settings: [{ type, type_label, vat_rate }] }
|
||||
*/
|
||||
public function vatUpdate(Request $request): JsonResponse
|
||||
{
|
||||
$validator = Validator::make($request->all(), [
|
||||
'settings' => 'required|array',
|
||||
'settings.*.type' => 'required|string',
|
||||
'settings.*.type_label' => 'sometimes|string|nullable',
|
||||
'settings.*.vat_rate' => 'required|numeric|min:0|max:100',
|
||||
]);
|
||||
|
||||
if ($validator->fails()) {
|
||||
return response()->json(['error' => true, 'message' => $validator->errors()->first()], 422);
|
||||
}
|
||||
|
||||
Setting::updateOrCreate(
|
||||
['key' => 'vat_settings'],
|
||||
['value' => json_encode($request->input('settings'), JSON_UNESCAPED_UNICODE)]
|
||||
);
|
||||
|
||||
return response()->json(['error' => false, 'message' => 'Cập nhật thuế VAT thành công.']);
|
||||
}
|
||||
|
||||
/**
|
||||
* POST /administrator/settings/test-telegram
|
||||
* Sends a test message using the currently saved Telegram config.
|
||||
|
||||
@@ -57,6 +57,10 @@ class AdminTransactionController extends Controller
|
||||
'public_code' => $t->public_code,
|
||||
'trans' => $t->trans,
|
||||
'amount' => $t->amount,
|
||||
// Số thực nhận trên sao kê; null = chưa duyệt. Chênh với `amount` chính là
|
||||
// phần khách chuyển thiếu/dư so với số đã khai.
|
||||
'received_amount' => $t->received_amount === null ? null : (int) $t->received_amount,
|
||||
'note' => $t->note,
|
||||
'type' => $t->type,
|
||||
'status' => $t->status,
|
||||
'status_label' => TransactionStatus::from((int) $t->status)->label(),
|
||||
@@ -84,12 +88,19 @@ class AdminTransactionController extends Controller
|
||||
]);
|
||||
}
|
||||
|
||||
public function approve(string $uuid): JsonResponse
|
||||
public function approve(Request $request, string $uuid): JsonResponse
|
||||
{
|
||||
// Số tiền THỰC NHẬN trên sao kê. Bỏ trống = tin số khách khai (hành vi cũ), nhưng
|
||||
// khi khách chuyển thiếu thì admin nhập đúng số đã về để số dư không bị cộng khống.
|
||||
$validated = $request->validate([
|
||||
'received_amount' => 'nullable|integer|min:0',
|
||||
'note' => 'nullable|string|max:1000',
|
||||
]);
|
||||
|
||||
try {
|
||||
// Lock the transaction (and customer) row for the whole read-check-write so two
|
||||
// admins approving the same transaction concurrently cannot both credit the customer.
|
||||
$transaction = DB::transaction(function () use ($uuid) {
|
||||
$transaction = DB::transaction(function () use ($uuid, $validated) {
|
||||
$transaction = Transaction::where('uuid', $uuid)
|
||||
->where('type', 'manual')
|
||||
->lockForUpdate()
|
||||
@@ -99,18 +110,47 @@ class AdminTransactionController extends Controller
|
||||
return null;
|
||||
}
|
||||
|
||||
$transaction->update(['status' => TransactionStatus::COMPLETED->value]);
|
||||
$declared = (int) $transaction->amount;
|
||||
$received = $validated['received_amount'] ?? $declared;
|
||||
$shortfall = $declared - $received;
|
||||
|
||||
$note = trim(implode(' ', array_filter([
|
||||
$transaction->note,
|
||||
$validated['note'] ?? null,
|
||||
$shortfall !== 0
|
||||
? sprintf(
|
||||
'[Lệch %s VNĐ: khai %s, thực nhận %s]',
|
||||
number_format(abs($shortfall)),
|
||||
number_format($declared),
|
||||
number_format($received),
|
||||
)
|
||||
: null,
|
||||
])));
|
||||
|
||||
$transaction->update([
|
||||
'status' => TransactionStatus::COMPLETED->value,
|
||||
'received_amount' => $received,
|
||||
'note' => $note !== '' ? $note : null,
|
||||
]);
|
||||
|
||||
$customer = Customer::where('id', $transaction->customer_id)->lockForUpdate()->first();
|
||||
$customer->total += $transaction->amount;
|
||||
$customer->credit += (int) floor($transaction->amount / 100);
|
||||
$customer->total_pending = max(0, $customer->total_pending - $transaction->amount);
|
||||
$creditEarned = Customer::creditFor($received);
|
||||
$customer->total += $received;
|
||||
$customer->credit += $creditEarned;
|
||||
// Trừ pending theo số khách khai — đó chính là số đã được giữ chỗ lúc tạo yêu cầu.
|
||||
$customer->total_pending = max(0, $customer->total_pending - $declared);
|
||||
$customer->save();
|
||||
|
||||
Log::create([
|
||||
'agent' => 'admin',
|
||||
'client_ip' => request()->ip(),
|
||||
'description' => 'Admin duyệt nạp tiền thủ công: ' . number_format($transaction->amount) . ' VNĐ (+' . floor($transaction->amount / 100) . ' credit)',
|
||||
'description' => sprintf(
|
||||
'Admin duyệt nạp tiền thủ công: thực nhận %s VNĐ (khai %s VNĐ) (+%d credit)%s',
|
||||
number_format($received),
|
||||
number_format($declared),
|
||||
$creditEarned,
|
||||
$shortfall > 0 ? ' — THIẾU ' . number_format($shortfall) . ' VNĐ' : '',
|
||||
),
|
||||
'customer_id' => $customer->id,
|
||||
]);
|
||||
|
||||
@@ -121,7 +161,7 @@ class AdminTransactionController extends Controller
|
||||
'link' => '/lich-su-giao-dich/' . $transaction->id,
|
||||
'data' => [
|
||||
'transaction_uuid' => $transaction->uuid,
|
||||
'amount' => $transaction->amount,
|
||||
'amount' => $received,
|
||||
'public_code' => $transaction->public_code,
|
||||
],
|
||||
]);
|
||||
@@ -151,7 +191,7 @@ class AdminTransactionController extends Controller
|
||||
'customer_email' => $customer->email,
|
||||
'customer_name' => $customer->fullname,
|
||||
'transaction_code' => $transaction->public_code,
|
||||
'amount' => $transaction->amount,
|
||||
'amount' => $transaction->received_amount ?? $transaction->amount,
|
||||
'status_label' => 'Đã duyệt',
|
||||
'status_color' => '#15803d',
|
||||
'status_bg' => '#f0fdf4',
|
||||
|
||||
@@ -9,6 +9,7 @@ use App\Http\Requests\Api\Client\ClientRegisterRequest;
|
||||
use App\Http\Requests\Api\Client\ClientUpdateCccdRequest;
|
||||
use App\Http\Requests\Api\Client\ClientUpdateRequest;
|
||||
use App\Http\Resources\Api\Client\ClientResource;
|
||||
use App\Models\Customer;
|
||||
use App\Services\ActivityLogger;
|
||||
use App\Services\Api\Client\ClientAuthService;
|
||||
use Illuminate\Http\Request;
|
||||
@@ -387,13 +388,13 @@ class ClientController extends Controller
|
||||
'email' => ['required', 'email', 'max:255'],
|
||||
]);
|
||||
|
||||
$customer = $this->authService->resetForgottenPassword($request->input('email'));
|
||||
$customer = $this->authService->sendPasswordResetLink($request->input('email'));
|
||||
|
||||
if ($customer) {
|
||||
$this->activityLogger->log(
|
||||
event: 'password_forgot',
|
||||
logName: 'Customer',
|
||||
description: "Customer {$customer->email} yêu cầu cấp lại mật khẩu",
|
||||
description: "Customer {$customer->email} yêu cầu link đặt lại mật khẩu",
|
||||
subject: $customer,
|
||||
);
|
||||
}
|
||||
@@ -401,10 +402,46 @@ class ClientController extends Controller
|
||||
// Do not disclose whether the email exists.
|
||||
return response()->json([
|
||||
'error' => false,
|
||||
'message' => 'Nếu email tồn tại, mật khẩu mới đã được gửi đến hộp thư của bạn.',
|
||||
'message' => 'Nếu email tồn tại, link đặt lại mật khẩu đã được gửi đến hộp thư của bạn.',
|
||||
]);
|
||||
}
|
||||
|
||||
public function resetPassword(Request $request)
|
||||
{
|
||||
$data = $request->validate([
|
||||
'email' => ['required', 'email', 'max:255'],
|
||||
'token' => ['required', 'string', 'max:255'],
|
||||
'password' => ['required', 'string', 'min:8', 'confirmed'],
|
||||
]);
|
||||
|
||||
$customer = $this->authService->resetPassword($data);
|
||||
|
||||
$this->activityLogger->log(
|
||||
event: 'password_reset',
|
||||
logName: 'Customer',
|
||||
description: "Customer {$customer->email} đặt lại mật khẩu qua email",
|
||||
subject: $customer,
|
||||
);
|
||||
|
||||
return response()->json([
|
||||
'error' => false,
|
||||
'message' => 'Đặt lại mật khẩu thành công, vui lòng đăng nhập lại.',
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Trả ảnh CCCD từ disk private. Route yêu cầu chữ ký (signed:relative) nên không cần
|
||||
* Bearer — chữ ký do ClientResource cấp cho đúng chủ tài khoản, hết hạn sau 60 phút.
|
||||
*/
|
||||
public function cccdImage(int $customer, string $side)
|
||||
{
|
||||
$path = Customer::findOrFail($customer)->{$side === 'front' ? 'front_identity_number' : 'back_identity_number'};
|
||||
|
||||
abort_unless($path && str_starts_with($path, 'cccd/') && Storage::disk('local')->exists($path), 404);
|
||||
|
||||
return Storage::disk('local')->response($path, null, ['Cache-Control' => 'private, no-store']);
|
||||
}
|
||||
|
||||
public function update(ClientUpdateRequest $request)
|
||||
{
|
||||
$result = $this->authService->updateProfile(auth('client')->user(), $request->validated());
|
||||
@@ -432,8 +469,9 @@ class ClientController extends Controller
|
||||
|
||||
public function updateCccd(ClientUpdateCccdRequest $request)
|
||||
{
|
||||
$frontUrl = Storage::disk('public')->url($request->file('cmnd_before')->store('cccd', 'public'));
|
||||
$backUrl = Storage::disk('public')->url($request->file('cmnd_after')->store('cccd', 'public'));
|
||||
// Disk private: ảnh chỉ đọc được qua route signed `api.client.cccd`.
|
||||
$frontUrl = $request->file('cmnd_before')->store('cccd', 'local');
|
||||
$backUrl = $request->file('cmnd_after')->store('cccd', 'local');
|
||||
|
||||
$result = $this->authService->updateCccd(
|
||||
auth('client')->user(),
|
||||
|
||||
@@ -1,917 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Api\Service;
|
||||
|
||||
use App\Enums\Invoice\InvoiceStatus;
|
||||
use App\Enums\Order\OrderStatus;
|
||||
use App\Helpers\OrderHelper;
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Jobs\VirtualMachine\Action\VMJobs;
|
||||
use App\Jobs\VirtualMachine\Create\CreateVirtualMachineJob;
|
||||
use App\Jobs\VirtualMachine\Provision\RetryProvisioningOrchestrator;
|
||||
use App\Models\Log as CustomerActivityLog;
|
||||
use App\Http\Requests\Api\Order\PayOrderRequest;
|
||||
use App\Http\Requests\Api\Order\StoreOrderRequest;
|
||||
use App\Models\Invoice;
|
||||
use App\Models\InvoiceItem;
|
||||
use App\Models\ModuleVmware;
|
||||
use App\Models\Order;
|
||||
use App\Models\OrderItem;
|
||||
use App\Models\Product;
|
||||
use App\Models\Service;
|
||||
use App\Models\VirtualMachines;
|
||||
use App\Repositories\Api\Order\OrderRepository;
|
||||
use App\Services\ActivityLogger;
|
||||
use App\Services\Api\Order\OrderService;
|
||||
use Carbon\Carbon;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
/**
|
||||
* @OA\Tag(name="Orders", description="Order & Invoice management")
|
||||
*/
|
||||
class OrderController extends Controller
|
||||
{
|
||||
public function __construct(
|
||||
protected OrderService $orderService,
|
||||
protected OrderRepository $orderRepo,
|
||||
protected ActivityLogger $activityLogger,
|
||||
) {}
|
||||
|
||||
// =========================================================================
|
||||
// Orders
|
||||
// =========================================================================
|
||||
|
||||
/**
|
||||
* GET /v1/orders — paginated order list (admin / internal)
|
||||
*/
|
||||
public function getList(Request $request): JsonResponse
|
||||
{
|
||||
try {
|
||||
$orders = $this->orderRepo->getOrders(
|
||||
$request->user()->id,
|
||||
$request->only(['total_price', 'pending'])
|
||||
);
|
||||
return response()->json(['success' => true, 'data' => $orders]);
|
||||
} catch (\Exception $e) {
|
||||
return $this->error($e->getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* GET /v1/orders/check-server-name?name=xxx
|
||||
* Kiểm tra tên server đã tồn tại chưa (order_items, services, module_vmware, virtual_machines)
|
||||
*/
|
||||
public function checkServerName(Request $request): JsonResponse
|
||||
{
|
||||
$name = trim($request->query('name', ''));
|
||||
|
||||
if ($name === '') {
|
||||
return response()->json(['available' => false, 'message' => 'Tên server không được để trống.']);
|
||||
}
|
||||
|
||||
$exists =
|
||||
OrderItem::where('server_name', $name)->exists() ||
|
||||
Service::where('server_name', $name)->exists() ||
|
||||
ModuleVmware::where('name', $name)->exists() ||
|
||||
ModuleVmware::where('hostname', $name)->exists() ||
|
||||
VirtualMachines::where('name', $name)->exists();
|
||||
|
||||
if ($exists) {
|
||||
return response()->json([
|
||||
'available' => false,
|
||||
'message' => 'Tên server này đã được sử dụng, vui lòng chọn tên khác.',
|
||||
]);
|
||||
}
|
||||
|
||||
return response()->json(['available' => true]);
|
||||
}
|
||||
|
||||
/**
|
||||
* GET /v1/client/orders — customer's order list
|
||||
*/
|
||||
public function getClientOrders(Request $request): JsonResponse
|
||||
{
|
||||
try {
|
||||
$orders = Order::query()
|
||||
->with(['invoice'])
|
||||
->where('customer_id', $request->user()->id)
|
||||
->orderByDesc('created_at')
|
||||
->get();
|
||||
|
||||
$data = $orders->map(fn(Order $order) => [
|
||||
'id' => $order->id,
|
||||
'order_num' => $order->order_num,
|
||||
'status' => $order->status,
|
||||
'status_text' => OrderStatus::from((int) $order->status)->label(),
|
||||
'total' => (float) $order->total,
|
||||
'created_at' => $this->isoDate($order->created_at),
|
||||
'invoice' => $order->invoice ? [
|
||||
'id' => $order->invoice->id,
|
||||
'invoice_number' => $order->invoice->invoice_number,
|
||||
'status' => $order->invoice->status,
|
||||
'total' => $order->invoice->total,
|
||||
] : null,
|
||||
]);
|
||||
|
||||
return response()->json(['success' => true, 'data' => $data]);
|
||||
} catch (\Exception $e) {
|
||||
return $this->error($e->getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* GET /v1/client/orders/{id} — order detail
|
||||
*/
|
||||
public function getClientOrderDetail(Request $request, int $id): JsonResponse
|
||||
{
|
||||
try {
|
||||
$order = Order::query()
|
||||
->with(['invoice', 'orderItems.product', 'orderItems.service', 'customer'])
|
||||
->where('customer_id', $request->user()->id)
|
||||
->where('id', $id)
|
||||
->first();
|
||||
|
||||
if (!$order) {
|
||||
return $this->notFound('Đơn hàng không tồn tại.');
|
||||
}
|
||||
|
||||
return response()->json(['success' => true, 'data' => $this->formatOrderDetail($order)]);
|
||||
} catch (\Exception $e) {
|
||||
return $this->error($e->getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* POST /v1/orders — create a new order
|
||||
*
|
||||
* @OA\Post(path="/orders", operationId="storeOrder", tags={"Orders"},
|
||||
* security={{"bearerAuth":{}}},
|
||||
* @OA\RequestBody(required=true, @OA\JsonContent(
|
||||
* required={"product_id","billing_cycle"},
|
||||
* @OA\Property(property="product_id", type="string", example="uuid-here"),
|
||||
* @OA\Property(property="billing_cycle", type="string", example="annually",
|
||||
* enum={"monthly","quarterly","semi_annually","annually","biennially"}),
|
||||
* @OA\Property(property="server_name", type="string", example="my-vps-01"),
|
||||
* @OA\Property(property="os_id", type="integer", example=1),
|
||||
* @OA\Property(property="addons", type="array", @OA\Items(
|
||||
* @OA\Property(property="addon_id", type="integer", example=1),
|
||||
* @OA\Property(property="qty", type="integer", example=2)
|
||||
* ))
|
||||
* )),
|
||||
* @OA\Response(response=201, description="Order created successfully")
|
||||
* )
|
||||
*/
|
||||
public function storeOrder(StoreOrderRequest $request): JsonResponse
|
||||
{
|
||||
try {
|
||||
$customer = $request->user();
|
||||
$data = $request->validated();
|
||||
|
||||
$product = Product::where('uuid', $data['product_id'])->first();
|
||||
if (!$product) {
|
||||
throw new \Exception('Sản phẩm không tìm thấy hoặc đã bị xóa.');
|
||||
}
|
||||
$data['product_id'] = $product->id;
|
||||
|
||||
$result = $this->orderService->create($data, $customer);
|
||||
|
||||
$order = $result['order']->load(['orderItems.product', 'invoice', 'customer']);
|
||||
$invoice = $result['invoice'];
|
||||
|
||||
$this->activityLogger->log(
|
||||
event: 'created',
|
||||
logName: 'Order',
|
||||
description: "Customer {$customer->email} tạo đơn hàng #{$order->order_num} - Tổng: " . number_format($order->total) . ' VNĐ',
|
||||
subject: $order,
|
||||
newValues: ['order_num' => $order->order_num, 'total' => $order->total, 'product_id' => $data['product_id']],
|
||||
);
|
||||
|
||||
return response()->json([
|
||||
'success' => true,
|
||||
'message' => 'Tạo đơn hàng thành công.',
|
||||
'notice' => 'Dịch vụ VPS sẽ được khởi tạo trong 5-10 phút sau khi thanh toán.',
|
||||
'data' => [
|
||||
'order' => $this->formatOrderSummary($order),
|
||||
'invoice' => $this->formatInvoiceSummary($invoice),
|
||||
'items' => $this->formatOrderItems($order->orderItems),
|
||||
],
|
||||
], 201);
|
||||
} catch (\Exception $e) {
|
||||
return $this->error($e->getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* POST /v1/orders/{id}/pay — pay for a pending order
|
||||
*
|
||||
* @OA\Post(path="/orders/{id}/pay", operationId="payOrder", tags={"Orders"},
|
||||
* security={{"bearerAuth":{}}},
|
||||
* @OA\Parameter(name="id", in="path", required=true, @OA\Schema(type="integer")),
|
||||
* @OA\RequestBody(required=true, @OA\JsonContent(
|
||||
* required={"payment_method"},
|
||||
* @OA\Property(property="payment_method", type="string",
|
||||
* enum={"credit","bank_transfer","vnpay","momo"})
|
||||
* )),
|
||||
* @OA\Response(response=200, description="Payment successful")
|
||||
* )
|
||||
*/
|
||||
public function payOrder(int $id, PayOrderRequest $request): JsonResponse
|
||||
{
|
||||
try {
|
||||
$order = $this->orderRepo->find($id);
|
||||
|
||||
if (!$order) {
|
||||
return $this->notFound('Đơn hàng không tồn tại.');
|
||||
}
|
||||
if ($order->customer_id !== $request->user()->id) {
|
||||
return response()->json(['success' => false, 'message' => 'Không có quyền truy cập.'], 403);
|
||||
}
|
||||
|
||||
$this->orderService->pay($order, $request->payment_method);
|
||||
|
||||
$this->activityLogger->log(
|
||||
event: 'payment',
|
||||
logName: 'Order',
|
||||
description: "Customer {$request->user()->email} thanh toán đơn hàng #{$order->order_num} qua {$request->payment_method}",
|
||||
subject: $order,
|
||||
newValues: ['payment_method' => $request->payment_method, 'total' => $order->total],
|
||||
);
|
||||
|
||||
return response()->json(['success' => true, 'message' => 'Thanh toán thành công.']);
|
||||
} catch (\Exception $e) {
|
||||
return $this->error($e->getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* POST /v1/client/orders/{id}/retry — retry VM provisioning for a paid order
|
||||
*
|
||||
* Dispatches RetryProvisioningOrchestrator which:
|
||||
* 1. Searches vSphere for existing VM by server_name
|
||||
* 2. If found → links VM to module_vmware + services
|
||||
* 3. If not found → dispatches CreateVirtualMachineJob
|
||||
* 4. Each failure step notifies Telegram
|
||||
*/
|
||||
public function retryOrderProvisioning(Request $request, int $id): JsonResponse
|
||||
{
|
||||
try {
|
||||
$order = Order::where('id', $id)
|
||||
->with(['invoice', 'orderItems.service'])
|
||||
->where('customer_id', $request->user()->id)
|
||||
->first();
|
||||
|
||||
if (!$order) {
|
||||
return $this->notFound('Đơn hàng không tồn tại.');
|
||||
}
|
||||
|
||||
if (!$order->invoice || (int) $order->invoice->status !== InvoiceStatus::PAID->value) {
|
||||
return $this->error('Đơn hàng chưa được thanh toán.');
|
||||
}
|
||||
|
||||
$paidAt = $order->invoice->paid_at ? Carbon::parse($order->invoice->paid_at) : null;
|
||||
if (!$paidAt || $paidAt->diffInMinutes(now()) < 180) {
|
||||
return $this->error('Vui lòng chờ khoảng 3 tiếng sau khi thanh toán mới có thể khởi tạo lại dịch vụ.');
|
||||
}
|
||||
|
||||
$service = $order->orderItems->first()?->service;
|
||||
if (!$service || (int) $service->customer_id !== (int) $request->user()->id) {
|
||||
return $this->error('Không tìm thấy dịch vụ hợp lệ để khởi tạo lại.');
|
||||
}
|
||||
|
||||
if ($service->status !== 'pending') {
|
||||
return $this->error('Dịch vụ đã được khởi tạo hoặc không ở trạng thái chờ.');
|
||||
}
|
||||
|
||||
if ($service->retry_requested_at) {
|
||||
return $this->error('Dịch vụ đã được yêu cầu khởi tạo lại một lần. Vui lòng liên hệ kỹ thuật qua mailto:kythuat@tgs.com.vn');
|
||||
}
|
||||
|
||||
$service->update(['retry_requested_at' => now()]);
|
||||
|
||||
RetryProvisioningOrchestrator::dispatch($order->id, $request->user()->id);
|
||||
|
||||
$this->activityLogger->log(
|
||||
event: 'retry_provisioning',
|
||||
logName: 'Order',
|
||||
description: "Customer {$request->user()->email} yêu cầu khởi tạo lại dịch vụ cho đơn hàng #{$order->order_num}",
|
||||
subject: $order,
|
||||
);
|
||||
|
||||
return response()->json([
|
||||
'success' => true,
|
||||
'message' => 'Đã yêu cầu khởi tạo lại dịch vụ. Hệ thống sẽ kiểm tra và xử lý trong vài phút.',
|
||||
]);
|
||||
} catch (\Exception $e) {
|
||||
return $this->error($e->getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// Invoices
|
||||
// =========================================================================
|
||||
|
||||
/**
|
||||
* GET /v1/client/invoices — customer's invoice list
|
||||
*/
|
||||
public function getClientInvoices(Request $request): JsonResponse
|
||||
{
|
||||
try {
|
||||
$invoices = Invoice::query()
|
||||
->with(['order.orderItems.product'])
|
||||
->where('customer_id', $request->user()->id)
|
||||
->orderByDesc('created_at')
|
||||
->get();
|
||||
|
||||
$data = $invoices->map(fn(Invoice $invoice) => [
|
||||
'id' => $invoice->id,
|
||||
'invoice_number' => $invoice->invoice_number,
|
||||
'service_name' => $invoice->order?->orderItems->first()?->product?->name
|
||||
?? ('Đơn hàng ' . $invoice->order?->order_num),
|
||||
'total' => (float) $invoice->total,
|
||||
'status' => $invoice->status,
|
||||
'status_text' => InvoiceStatus::tryFrom((int) $invoice->status)?->label() ?? '-',
|
||||
'created_at' => $this->isoDate($invoice->created_at),
|
||||
'order_id' => $invoice->order?->id,
|
||||
'order_num' => $invoice->order?->order_num,
|
||||
])->values();
|
||||
|
||||
return response()->json(['success' => true, 'data' => $data]);
|
||||
} catch (\Exception $e) {
|
||||
return $this->error($e->getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* GET /v1/client/invoices/{id} — invoice detail
|
||||
*/
|
||||
public function getClientInvoiceDetail(Request $request, int $id): JsonResponse
|
||||
{
|
||||
try {
|
||||
$invoice = Invoice::query()
|
||||
->with([
|
||||
'order.orderItems.product',
|
||||
'order.vmWare.ipAddress',
|
||||
'order.vmWare.os',
|
||||
'customer',
|
||||
'attachments',
|
||||
])
|
||||
->where('customer_id', $request->user()->id)
|
||||
->where('id', $id)
|
||||
->first();
|
||||
|
||||
if (!$invoice) {
|
||||
return $this->notFound('Hóa đơn không tồn tại.');
|
||||
}
|
||||
|
||||
return response()->json(['success' => true, 'data' => $this->formatInvoiceDetail($invoice)]);
|
||||
} catch (\Exception $e) {
|
||||
return $this->error($e->getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* GET /v1/client/invoices/order/{orderNumber} — invoice detail by order number
|
||||
*/
|
||||
public function getClientInvoiceDetailByOrderNum(Request $request, string $orderNumber): JsonResponse
|
||||
{
|
||||
try {
|
||||
$order = Order::query()
|
||||
->with([
|
||||
'invoice.invoiceItems',
|
||||
'invoice.attachments',
|
||||
'invoice.customer',
|
||||
'orderItems.product',
|
||||
'customer',
|
||||
'vmWare.ipAddress',
|
||||
'vmWare.os',
|
||||
])
|
||||
->where('customer_id', $request->user()->id)
|
||||
->where('order_num', $orderNumber)
|
||||
->first();
|
||||
|
||||
if (!$order?->invoice) {
|
||||
return $this->notFound('Hóa đơn không tồn tại.');
|
||||
}
|
||||
|
||||
// Reuse formatInvoiceDetail with already-loaded relations
|
||||
$invoice = $order->invoice;
|
||||
// Attach order + vmWare so formatter can use them
|
||||
$invoice->setRelation('order', $order);
|
||||
$invoice->setRelation('customer', $order->customer);
|
||||
|
||||
return response()->json(['success' => true, 'data' => $this->formatInvoiceDetail($invoice)]);
|
||||
} catch (\Exception $e) {
|
||||
return $this->error($e->getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// Services (dich-vu)
|
||||
// =========================================================================
|
||||
|
||||
/**
|
||||
* GET /v1/client/dich-vu — list of customer's services
|
||||
*/
|
||||
public function getClientServices(Request $request): JsonResponse
|
||||
{
|
||||
try {
|
||||
$services = Service::query()
|
||||
->with(['product', 'addons', 'invoice', 'orderItem', 'vmware.ipAddress', 'vmware.os'])
|
||||
->where('customer_id', $request->user()->id)
|
||||
->orderByDesc('created_at')
|
||||
->get();
|
||||
|
||||
$data = $services->map(function (Service $service) {
|
||||
$vmware = $service->vmware;
|
||||
|
||||
return [
|
||||
'id' => $service->id,
|
||||
'service_name' => $service->product?->name ?? $service->server_name,
|
||||
'server_name' => $service->server_name,
|
||||
'product_type' => $service->product?->product_type,
|
||||
'billing_cycle' => $service->orderItem?->billing_cycle,
|
||||
'status' => $service->status,
|
||||
'status_text' => $service->status_label,
|
||||
'auto_renew' => (bool) $service->auto_renew,
|
||||
'started_at' => $this->isoDate($service->started_at),
|
||||
'expires_at' => $this->isoDate($service->expires_at),
|
||||
'invoice_id' => $service->invoice_id,
|
||||
'invoice_number' => $service->invoice?->invoice_number,
|
||||
'addons' => $service->addons->map(fn($a) => [
|
||||
'type' => $a->addon_type,
|
||||
'value' => $a->addon_value,
|
||||
'price' => $a->price,
|
||||
])->all(),
|
||||
'vmware' => $vmware ? [
|
||||
'id' => $vmware->id,
|
||||
'name' => $vmware->name,
|
||||
'hostname' => $vmware->hostname,
|
||||
'ip_address' => $vmware->ipAddress?->ip,
|
||||
'os_name' => $vmware->os?->name,
|
||||
'status' => $vmware->status,
|
||||
] : null,
|
||||
];
|
||||
})->values();
|
||||
|
||||
return response()->json(['success' => true, 'data' => $data]);
|
||||
} catch (\Exception $e) {
|
||||
return $this->error($e->getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* GET /v1/client/dich-vu/{uuid} — service detail by UUID
|
||||
*/
|
||||
public function getClientServiceDetail(Request $request, string $uuid): JsonResponse
|
||||
{
|
||||
try {
|
||||
$service = Service::query()
|
||||
->with(['product', 'addons', 'invoice', 'orderItem', 'vmware.ipAddress', 'vmware.os', 'vmware.vCenter'])
|
||||
->where('uuid', $uuid)
|
||||
->where('customer_id', $request->user()->id)
|
||||
->first();
|
||||
|
||||
if (!$service) {
|
||||
return $this->notFound('Không tìm thấy dịch vụ.');
|
||||
}
|
||||
|
||||
$vmware = $service->vmware;
|
||||
|
||||
$data = [
|
||||
'id' => $service->id,
|
||||
'uuid' => $service->uuid,
|
||||
'service_name' => $service->product?->name ?? $service->server_name,
|
||||
'server_name' => $service->server_name,
|
||||
'product_type' => $service->product?->product_type,
|
||||
'billing_cycle' => $service->orderItem?->billing_cycle,
|
||||
'status' => $service->status,
|
||||
'status_text' => $service->status_label,
|
||||
'auto_renew' => (bool) $service->auto_renew,
|
||||
'started_at' => $this->isoDate($service->started_at),
|
||||
'expires_at' => $this->isoDate($service->expires_at),
|
||||
'note' => $service->note,
|
||||
'invoice_id' => $service->invoice_id,
|
||||
'invoice_number' => $service->invoice?->invoice_number,
|
||||
'product' => $service->product ? [
|
||||
'id' => $service->product->id,
|
||||
'uuid' => $service->product->uuid,
|
||||
'name' => $service->product->name,
|
||||
'product_type' => $service->product->product_type,
|
||||
'specs' => [
|
||||
'cpu' => $service->product->cpu,
|
||||
'ram' => $service->product->ram,
|
||||
'disk' => $service->product->disk,
|
||||
'bandwidth' => $service->product->bandwidth,
|
||||
],
|
||||
] : null,
|
||||
'addons' => $service->addons->map(fn($a) => [
|
||||
'type' => $a->addon_type,
|
||||
'value' => $a->addon_value,
|
||||
'price' => $a->price,
|
||||
])->all(),
|
||||
'vmware' => $vmware ? [
|
||||
'id' => $vmware->id,
|
||||
'name' => $vmware->name,
|
||||
'hostname' => $vmware->hostname,
|
||||
'ip_address' => $vmware->ipAddress?->ip,
|
||||
'os_name' => $vmware->os?->name,
|
||||
'status' => $vmware->status,
|
||||
] : null,
|
||||
];
|
||||
|
||||
return response()->json(['success' => true, 'data' => $data]);
|
||||
} catch (\Exception $e) {
|
||||
return $this->error($e->getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* PATCH /v1/client/dich-vu/{id}/auto-renew — toggle auto-renewal on a service
|
||||
*/
|
||||
public function updateClientServiceAutoRenew(Request $request, int $id): JsonResponse
|
||||
{
|
||||
try {
|
||||
$validated = $request->validate(['auto_renew' => ['required', 'boolean']]);
|
||||
|
||||
$service = Service::query()
|
||||
->where('id', $id)
|
||||
->where('customer_id', $request->user()->id)
|
||||
->first();
|
||||
|
||||
if (!$service) {
|
||||
return $this->notFound('Không tìm thấy dịch vụ.');
|
||||
}
|
||||
|
||||
$service->update(['auto_renew' => (bool) $validated['auto_renew']]);
|
||||
|
||||
$this->activityLogger->log(
|
||||
event: 'updated',
|
||||
logName: 'Service',
|
||||
description: "Customer {$request->user()->email} " . ($validated['auto_renew'] ? 'bật' : 'tắt') . " tự động gia hạn dịch vụ #{$service->id}",
|
||||
subject: $service,
|
||||
newValues: ['auto_renew' => (bool) $validated['auto_renew']],
|
||||
);
|
||||
|
||||
return response()->json([
|
||||
'success' => true,
|
||||
'message' => 'Cập nhật tự động gia hạn thành công.',
|
||||
'data' => ['id' => $service->id, 'auto_renew' => (bool) $service->auto_renew],
|
||||
]);
|
||||
} catch (\Exception $e) {
|
||||
return $this->error($e->getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// Services — renewal
|
||||
// =========================================================================
|
||||
|
||||
/**
|
||||
* POST /v1/client/dich-vu/{id}/renew
|
||||
*
|
||||
* Pay-and-activate renewal in one step:
|
||||
* 1. Validate balance
|
||||
* 2. Debit customer balance (inside transaction)
|
||||
* 3. Create PAID renewal invoice (inside transaction)
|
||||
* 4. Extend service expires_at + set status = active (inside transaction)
|
||||
* 5. Dispatch VMJobs 'Start' OUTSIDE transaction to power on suspended VM
|
||||
*/
|
||||
public function renewService(Request $request, int $id): JsonResponse
|
||||
{
|
||||
try {
|
||||
$customer = $request->user();
|
||||
|
||||
$service = Service::query()
|
||||
->with(['orderItem.product'])
|
||||
->where('id', $id)
|
||||
->where('customer_id', $customer->id)
|
||||
->whereNotIn('status', ['terminated', 'cancelled'])
|
||||
->first();
|
||||
|
||||
if (!$service) {
|
||||
return $this->notFound('Không tìm thấy dịch vụ hoặc dịch vụ đã bị hủy.');
|
||||
}
|
||||
|
||||
$orderItem = $service->orderItem;
|
||||
if (!$orderItem || (int) $orderItem->price <= 0) {
|
||||
return $this->error('Không thể xác định giá gia hạn dịch vụ.');
|
||||
}
|
||||
|
||||
$validated = $request->validate(['months' => ['required', 'integer', 'min:1', 'max:24']]);
|
||||
|
||||
$price = (int) $orderItem->price * (int) $validated['months'];
|
||||
$months = (int) $validated['months'];
|
||||
|
||||
// Validate balance before opening a transaction
|
||||
if ($customer->total < $price) {
|
||||
return $this->error(sprintf(
|
||||
'Số dư không đủ. Hiện có: %s VNĐ, cần: %s VNĐ.',
|
||||
number_format($customer->total),
|
||||
number_format($price)
|
||||
));
|
||||
}
|
||||
|
||||
$needsVmStart = $service->vm_id !== null;
|
||||
|
||||
$invoice = DB::transaction(function () use ($customer, $service, $orderItem, $price, $months) {
|
||||
// 1. Debit balance
|
||||
$customer->total -= $price;
|
||||
$customer->save();
|
||||
|
||||
// 2. Activity log
|
||||
CustomerActivityLog::create([
|
||||
'agent' => 'api',
|
||||
'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)
|
||||
),
|
||||
]);
|
||||
|
||||
// 3. Create PAID renewal invoice
|
||||
$invoice = Invoice::create([
|
||||
'invoice_number' => OrderHelper::generateOrderNumber('INV'),
|
||||
'customer_id' => $customer->id,
|
||||
'order_id' => null,
|
||||
'invoice_type' => 2, // renewal
|
||||
'sub_total' => $price,
|
||||
'discount_amount' => 0,
|
||||
'tax' => 0,
|
||||
'total' => $price,
|
||||
'status' => InvoiceStatus::PAID->value,
|
||||
'paid_at' => now(),
|
||||
'payment_method' => 'credit',
|
||||
]);
|
||||
|
||||
InvoiceItem::create([
|
||||
'invoice_id' => $invoice->id,
|
||||
'product_id' => $service->product_id,
|
||||
'type' => 'renewal',
|
||||
'description' => 'Gia hạn dịch vụ: ' . ($orderItem->product?->name ?? $service->server_name ?? ''),
|
||||
'months' => $months,
|
||||
'quantity' => 1,
|
||||
'unit_price' => $price,
|
||||
'total_price' => $price,
|
||||
]);
|
||||
|
||||
// 4. Extend expires_at from today (or current expires_at if still in future)
|
||||
$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,
|
||||
]);
|
||||
|
||||
return $invoice;
|
||||
});
|
||||
|
||||
// 5. Start VM outside transaction (DB already committed)
|
||||
if ($needsVmStart) {
|
||||
dispatch(new VMJobs((int) $service->vm_id, 'Start', false))
|
||||
->delay(now()->addSeconds(5));
|
||||
}
|
||||
|
||||
$this->activityLogger->log(
|
||||
event: 'renew',
|
||||
logName: 'Service',
|
||||
description: "Customer {$customer->email} gia hạn dịch vụ #{$service->id} ({$service->server_name}) - {$months} tháng - " . number_format($price) . ' VNĐ',
|
||||
subject: $service,
|
||||
newValues: ['months' => $months, 'price' => $price, 'invoice_number' => $invoice->invoice_number],
|
||||
);
|
||||
|
||||
return response()->json([
|
||||
'success' => true,
|
||||
'message' => 'Gia hạn thành công. Dịch vụ đã được kích hoạt lại.',
|
||||
'data' => [
|
||||
'invoice_number' => $invoice->invoice_number,
|
||||
'total' => (float) $invoice->total,
|
||||
'expires_at' => $service->fresh()?->expires_at?->toISOString(),
|
||||
],
|
||||
]);
|
||||
} catch (\Exception $e) {
|
||||
return $this->error($e->getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// Private — formatters
|
||||
// =========================================================================
|
||||
|
||||
private function formatOrderSummary(Order $order): array
|
||||
{
|
||||
return [
|
||||
'id' => $order->id,
|
||||
'order_num' => $order->order_num,
|
||||
'status' => $order->status,
|
||||
'status_text' => OrderStatus::from((int) $order->status)->label(),
|
||||
'sub_total' => $order->sub_total,
|
||||
'total' => $order->total,
|
||||
'created_at' => $this->isoDate($order->created_at),
|
||||
];
|
||||
}
|
||||
|
||||
private function formatInvoiceSummary(Invoice $invoice): array
|
||||
{
|
||||
return [
|
||||
'id' => $invoice->id,
|
||||
'invoice_number' => $invoice->invoice_number,
|
||||
'sub_total' => $invoice->sub_total,
|
||||
'tax' => $invoice->tax,
|
||||
'total' => $invoice->total,
|
||||
'status' => $invoice->status,
|
||||
'due_date' => $this->isoDate($invoice->due_date),
|
||||
];
|
||||
}
|
||||
|
||||
private function formatOrderItems($items): array
|
||||
{
|
||||
return $items->map(fn($item) => [
|
||||
'id' => $item->id,
|
||||
'billing_cycle' => $item->billing_cycle,
|
||||
'months' => $item->months,
|
||||
'server_name' => $item->server_name,
|
||||
'os_id' => $item->os_id,
|
||||
'price' => (float) $item->price,
|
||||
'service' => $item->service ? [
|
||||
'id' => $item->service->id,
|
||||
'status' => $item->service->status,
|
||||
'status_text' => $item->service->status_label,
|
||||
'retry_requested_at' => $this->isoDate($item->service->retry_requested_at),
|
||||
'can_retry_provisioning' => $this->canRetryProvisioning($item->service),
|
||||
] : null,
|
||||
'product' => $item->product ? [
|
||||
'id' => $item->product->id,
|
||||
'uuid' => $item->product->uuid,
|
||||
'name' => $item->product->name,
|
||||
'product_type' => $item->product->product_type,
|
||||
'specs' => [
|
||||
'cpu' => $item->product->cpu,
|
||||
'ram' => $item->product->ram,
|
||||
'disk' => $item->product->disk,
|
||||
'bandwidth' => $item->product->bandwidth,
|
||||
],
|
||||
] : null,
|
||||
])->all();
|
||||
}
|
||||
|
||||
private function formatOrderDetail(Order $order): array
|
||||
{
|
||||
$invoice = $order->invoice;
|
||||
|
||||
return [
|
||||
'id' => $order->id,
|
||||
'order_num' => $order->order_num,
|
||||
'status' => $order->status,
|
||||
'status_text' => OrderStatus::from((int) $order->status)->label(),
|
||||
'total' => (float) $order->total,
|
||||
'created_at' => $this->isoDate($order->created_at),
|
||||
'customer' => [
|
||||
'id' => $order->customer?->id,
|
||||
'name' => $order->customer?->fullname,
|
||||
'email' => $order->customer?->email,
|
||||
],
|
||||
'invoice' => $invoice ? [
|
||||
'id' => $invoice->id,
|
||||
'invoice_number' => $invoice->invoice_number,
|
||||
'status' => $invoice->status,
|
||||
'status_text' => InvoiceStatus::tryFrom((int) $invoice->status)?->label() ?? '-',
|
||||
'sub_total' => $invoice->sub_total,
|
||||
'tax' => $invoice->tax,
|
||||
'total' => $invoice->total,
|
||||
'due_date' => $this->isoDate($invoice->due_date),
|
||||
'paid_at' => $this->isoDate($invoice->paid_at),
|
||||
] : null,
|
||||
'items' => $this->formatOrderItems($order->orderItems),
|
||||
];
|
||||
}
|
||||
|
||||
private function canRetryProvisioning(Service $service): bool
|
||||
{
|
||||
if ($service->status !== 'pending') {
|
||||
return false;
|
||||
}
|
||||
|
||||
if ($service->retry_requested_at) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$paidAt = $service->invoice?->paid_at;
|
||||
if (!$paidAt) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return Carbon::parse($paidAt)->diffInMinutes(now()) >= 180;
|
||||
}
|
||||
|
||||
private function formatInvoiceDetail(Invoice $invoice): array
|
||||
{
|
||||
$order = $invoice->order;
|
||||
$vmware = $order?->vmWare->first();
|
||||
|
||||
return [
|
||||
'id' => $invoice->id,
|
||||
'invoice_number' => $invoice->invoice_number,
|
||||
'invoice_type' => $invoice->invoice_type,
|
||||
'status' => $invoice->status,
|
||||
'status_text' => InvoiceStatus::tryFrom((int) $invoice->status)?->label() ?? '-',
|
||||
'sub_total' => (float) $invoice->sub_total,
|
||||
'discount_amount' => (float) ($invoice->discount_amount ?? 0),
|
||||
'tax' => (float) $invoice->tax,
|
||||
'total' => (float) $invoice->total,
|
||||
'payment_method' => $invoice->payment_method,
|
||||
'auto_renew' => (bool) $invoice->auto_renew,
|
||||
'created_at' => $this->isoDate($invoice->created_at),
|
||||
'due_date' => $this->isoDate($invoice->due_date),
|
||||
'paid_at' => $this->isoDate($invoice->paid_at),
|
||||
'customer' => $invoice->customer ? [
|
||||
'id' => $invoice->customer->id,
|
||||
'name' => $invoice->customer->fullname,
|
||||
'email' => $invoice->customer->email,
|
||||
'address' => $invoice->customer->address,
|
||||
] : null,
|
||||
'order' => $order ? [
|
||||
'id' => $order->id,
|
||||
'order_num' => $order->order_num,
|
||||
'total' => (float) $order->total,
|
||||
] : null,
|
||||
'service' => ($firstItem = $order?->orderItems->first()) ? [
|
||||
'product_id' => $firstItem->product?->id,
|
||||
'product_uuid' => $firstItem->product?->uuid,
|
||||
'name' => $firstItem->product?->name,
|
||||
'billing_cycle' => $firstItem->billing_cycle,
|
||||
'server_name' => $firstItem->server_name,
|
||||
'specs' => [
|
||||
'cpu' => $firstItem->product?->cpu,
|
||||
'ram' => $firstItem->product?->ram,
|
||||
'disk' => $firstItem->product?->disk,
|
||||
],
|
||||
] : null,
|
||||
'items' => $order ? $order->orderItems->map(fn($item) => [
|
||||
'id' => $item->id,
|
||||
'product_name' => $item->product?->name,
|
||||
'server_name' => $item->server_name,
|
||||
'billing_cycle' => $item->billing_cycle,
|
||||
'price' => (float) $item->price,
|
||||
'months' => $item->months,
|
||||
'os_id' => $item->os_id,
|
||||
])->values()->all() : [],
|
||||
'attachments' => ($invoice->relationLoaded('attachments') ? $invoice->attachments : collect())->map(fn($a) => [
|
||||
'id' => $a->id,
|
||||
'filename' => $a->filename,
|
||||
'url' => $a->url,
|
||||
'mime_type' => $a->mime_type,
|
||||
])->all(),
|
||||
'vmware' => $vmware ? [
|
||||
'id' => $vmware->id,
|
||||
'name' => $vmware->name,
|
||||
'hostname' => $vmware->hostname,
|
||||
'ip_address' => $vmware->ipAddress?->ip,
|
||||
'os_name' => $vmware->os?->name,
|
||||
'status' => $vmware->status,
|
||||
'next_due_date' => $this->isoDate($vmware->next_due_date),
|
||||
] : null,
|
||||
];
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// Micro-helpers
|
||||
// =========================================================================
|
||||
|
||||
private function isoDate($value): ?string
|
||||
{
|
||||
if (empty($value)) {
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
if ($value instanceof \DateTimeInterface) {
|
||||
return Carbon::instance($value)->toISOString();
|
||||
}
|
||||
if (is_numeric($value)) {
|
||||
return Carbon::createFromTimestamp((int) $value)->toISOString();
|
||||
}
|
||||
return Carbon::parse($value)->toISOString();
|
||||
} catch (\Throwable) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private function error(string $message, int $code = 400): JsonResponse
|
||||
{
|
||||
return response()->json(['success' => false, 'message' => $message], $code);
|
||||
}
|
||||
|
||||
private function notFound(string $message): JsonResponse
|
||||
{
|
||||
return response()->json(['success' => false, 'message' => $message], 404);
|
||||
}
|
||||
}
|
||||
@@ -118,7 +118,13 @@ class OrderControllerRefactor extends Controller
|
||||
return response()->json(['success' => false, 'message' => 'Đơn hàng không tồn tại.'], 404);
|
||||
}
|
||||
|
||||
$this->orderService->pay($order, $request->payment_method);
|
||||
try {
|
||||
$this->orderService->pay($order, $request->payment_method);
|
||||
} catch (\Throwable $e) {
|
||||
// Số dư không đủ / đơn đã thanh toán là lỗi nghiệp vụ, không phải lỗi server —
|
||||
// trả 422 cho khớp với payClientInvoiceByNumber.
|
||||
return response()->json(['success' => false, 'message' => $e->getMessage()], 422);
|
||||
}
|
||||
|
||||
$this->activityLogger->log(
|
||||
event: 'payment',
|
||||
@@ -213,8 +219,19 @@ class OrderControllerRefactor extends Controller
|
||||
return response()->json(['success' => false, 'message' => 'Hóa đơn không tồn tại.'], 404);
|
||||
}
|
||||
|
||||
// Bỏ trống `amount` = trả hết phần còn thiếu. Truyền số nhỏ hơn để trả từng phần khi
|
||||
// số dư chưa đủ — hoá đơn giữ nguyên chưa thanh toán cho tới khi thu đủ.
|
||||
$validated = $request->validate([
|
||||
'amount' => 'nullable|integer|min:1',
|
||||
]);
|
||||
|
||||
try {
|
||||
$paidInvoice = $this->invoicePaymentService->payWithBalance($invoice, $request->user(), 'balance');
|
||||
$paidInvoice = $this->invoicePaymentService->payWithBalance(
|
||||
$invoice,
|
||||
$request->user(),
|
||||
'balance',
|
||||
$validated['amount'] ?? null,
|
||||
);
|
||||
|
||||
$this->activityLogger->log(
|
||||
event: 'payment',
|
||||
@@ -224,9 +241,13 @@ class OrderControllerRefactor extends Controller
|
||||
newValues: ['invoice_number' => $paidInvoice->invoice_number, 'total' => $paidInvoice->total],
|
||||
);
|
||||
|
||||
$outstanding = \App\Services\InvoicePaymentService::outstanding($paidInvoice);
|
||||
|
||||
return response()->json([
|
||||
'success' => true,
|
||||
'message' => 'Thanh toán hóa đơn thành công.',
|
||||
'message' => $outstanding > 0
|
||||
? 'Đã ghi nhận thanh toán một phần. Còn thiếu ' . number_format($outstanding) . ' VNĐ để kích hoạt dịch vụ.'
|
||||
: 'Thanh toán hóa đơn thành công.',
|
||||
'data' => new InvoiceDetailResource($paidInvoice),
|
||||
]);
|
||||
} catch (\Throwable $e) {
|
||||
|
||||
@@ -527,8 +527,9 @@ class CustomerController extends Controller
|
||||
if (isset($request->email_id)) {
|
||||
$template = $this->mailRepository->find($request->get('email_id'));
|
||||
if ($template) {
|
||||
$title = $template->subject;
|
||||
return view("emails.render", compact('title', 'template'));
|
||||
// Blade emails.render đã bị xoá cùng toàn bộ view email — nội dung mail đã là
|
||||
// HTML hoàn chỉnh render từ tbl.template, trả thẳng ra là đủ.
|
||||
return response($template->message ?? '', 200, ['Content-Type' => 'text/html; charset=UTF-8']);
|
||||
}
|
||||
}
|
||||
return view('administrator.pages.customer.mail', compact('userid', 'title', 'emails'));
|
||||
|
||||
Reference in New Issue
Block a user