diff --git a/.DS_Store b/.DS_Store
index e187da8..90f1afb 100644
Binary files a/.DS_Store and b/.DS_Store differ
diff --git a/app/Console/Commands/CheckOverdueInvoices.php b/app/Console/Commands/CheckOverdueInvoices.php
index 79e5d79..1b4b3dd 100644
--- a/app/Console/Commands/CheckOverdueInvoices.php
+++ b/app/Console/Commands/CheckOverdueInvoices.php
@@ -3,7 +3,6 @@
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;
@@ -12,7 +11,7 @@ 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.';
+ protected $description = 'Mark unpaid invoices past due date as overdue.';
public function handle(): int
{
@@ -36,8 +35,6 @@ class CheckOverdueInvoices extends Command
}
$invoices = Invoice::query()
- // customer_id must be selected too: OrderObserver::updated() reads it when
- // logging the status change below, and a NULL there violates log.customer_id NOT NULL.
->with('order:id,status,customer_id')
->whereNotNull('due_date')
->whereDate('due_date', '<', $today)
@@ -47,36 +44,28 @@ class CheckOverdueInvoices extends Command
$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}" : ''
+ $invoice->order_id ? " | order #{$invoice->order_id}" : ''
));
- if ($dryRun || (! $needsInvoiceUpdate && ! $needsOrderUpdate)) {
+ if ($dryRun || ! $needsInvoiceUpdate) {
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++;
- }
+ // Chỉ đổi trạng thái hoá đơn. Trước đây command còn đẩy order về PENDING, nhưng hoá đơn
+ // gia hạn dùng lại order_id gốc nên một kỳ gia hạn quá hạn sẽ kéo đơn đang chạy về
+ // "Chờ xử lý" — vừa hiển thị sai, vừa khiến hasPendingOrder() khoá khách đặt đơn mới.
+ DB::transaction(function () use ($invoice, &$updatedInvoices) {
+ $invoice->update(['status' => InvoiceStatus::OVERDUE->value]);
+ $updatedInvoices++;
});
Log::info('Invoice marked overdue', [
@@ -86,7 +75,7 @@ class CheckOverdueInvoices extends Command
]);
}
- $this->info("invoices:check-overdue | Updated {$updatedInvoices} invoice(s), {$updatedOrders} order(s).");
+ $this->info("invoices:check-overdue | Updated {$updatedInvoices} invoice(s).");
return Command::SUCCESS;
}
diff --git a/app/Console/Commands/SendInvoiceReminders.php b/app/Console/Commands/SendInvoiceReminders.php
new file mode 100644
index 0000000..79f091a
--- /dev/null
+++ b/app/Console/Commands/SendInvoiceReminders.php
@@ -0,0 +1,79 @@
+option('dry-run');
+ $offsets = (array) config('billing.reminder_offsets', []);
+
+ if ($offsets === []) {
+ $this->warn('invoices:send-reminders | billing.reminder_offsets rỗng — không có mốc nào để nhắc.');
+ return Command::SUCCESS;
+ }
+
+ // Chỉ quét hoá đơn nằm trong cửa sổ nhắc [mốc sớm nhất … mốc muộn nhất], để không
+ // kéo cả bảng và không đụng vào hoá đơn cũ đã qua mốc cuối (vd. dữ liệu import từ WHMCS).
+ $earliest = min($offsets);
+ $latest = InvoiceReminderService::reminderCutoff($offsets);
+
+ $invoices = Invoice::query()
+ ->with('customer')
+ ->whereNotNull('due_date')
+ ->whereIn('status', InvoicePaymentService::PAYABLE_STATUSES)
+ ->whereDate('due_date', '<=', now()->subDays($earliest)->toDateString())
+ ->whereDate('due_date', '>=', now()->subDays($latest)->toDateString())
+ ->get();
+
+ $this->info("invoices:send-reminders | Xét {$invoices->count()} hoá đơn.");
+
+ $sent = 0;
+
+ foreach ($invoices as $invoice) {
+ $dueStep = $reminders->dueStep($invoice);
+
+ if ($dueStep <= (int) $invoice->reminder_step) {
+ continue;
+ }
+
+ if ($dryRun) {
+ $this->line(sprintf(
+ ' [dry-run] Hoá đơn #%s hạn %s — mốc %d/%d',
+ $invoice->invoice_number,
+ $invoice->due_date->format('Y-m-d'),
+ $dueStep,
+ count($offsets),
+ ));
+ continue;
+ }
+
+ try {
+ if ($reminders->remind($invoice)) {
+ $sent++;
+ $this->line(" [sent] Hoá đơn #{$invoice->invoice_number} → {$invoice->customer?->email}");
+ }
+ } catch (\Throwable $e) {
+ Log::error('invoices:send-reminders error', [
+ 'invoice_id' => $invoice->id,
+ 'error' => $e->getMessage(),
+ ]);
+ $this->error(" [error] Hoá đơn #{$invoice->invoice_number}: " . $e->getMessage());
+ }
+ }
+
+ $this->info("invoices:send-reminders | Đã gửi {$sent} email nhắc.");
+
+ return Command::SUCCESS;
+ }
+}
diff --git a/app/Console/Commands/ServiceExpiry.php b/app/Console/Commands/ServiceExpiry.php
index 32551e6..411589d 100644
--- a/app/Console/Commands/ServiceExpiry.php
+++ b/app/Console/Commands/ServiceExpiry.php
@@ -4,7 +4,10 @@ namespace App\Console\Commands;
use App\Enums\Invoice\InvoiceStatus;
use App\Helpers\OrderHelper;
+use App\Jobs\VirtualMachine\Action\RemoveVirtualMachine;
use App\Jobs\VirtualMachine\Action\VMJobs;
+use App\Models\OrderItem;
+use App\Repositories\Api\Service\ServiceRepository;
use App\Models\Invoice;
use App\Models\InvoiceItem;
use App\Models\Service;
@@ -55,15 +58,17 @@ class ServiceExpiry extends Command
foreach ($expired as $service) {
try {
- // Fix: diffInDays is always positive; guard with greaterThan to avoid edge case at exact boundary
+ // Hướng tính tường minh (mốc quá khứ → now), absolute: true để không phụ thuộc
+ // việc Carbon 2 trả trị tuyệt đối — Carbon 3 trả số âm, điều kiện >= 30 sẽ
+ // không bao giờ đúng và không dịch vụ nào bị terminate nữa, hoàn toàn im lặng.
$daysExpired = now()->greaterThan($service->expires_at)
- ? (int) now()->diffInDays($service->expires_at)
+ ? (int) $service->expires_at->diffInDays(now(), absolute: true)
: 0;
// --- 30-day threshold: terminate (idempotent) ---
- if ($daysExpired >= 30) {
+ if ($daysExpired >= 30 + (int) config('billing.terminate_grace_days', 0)) {
if ($dryRun) {
- $this->line(" [dry-run:terminate] Service #{$service->id}");
+ $this->line(" [dry-run:terminate] Service #{$service->id}" . ($service->vm_id ? " + thu hồi VM #{$service->vm_id}" : ''));
continue;
}
@@ -71,6 +76,8 @@ class ServiceExpiry extends Command
$service->update(['status' => 'terminated']);
Log::info('Service terminated', ['service_id' => $service->id, 'days_overdue' => $daysExpired]);
$this->line(" [terminated] Service #{$service->id}");
+
+ $this->reclaimResources($service);
}
continue;
}
@@ -118,6 +125,75 @@ class ServiceExpiry extends Command
return Command::SUCCESS;
}
+ /**
+ * Giá gia hạn lấy theo BẢNG GIÁ HIỆN HÀNH tại thời điểm xuất hoá đơn, không đóng băng theo
+ * giá lúc mua — nếu không, tăng giá sẽ không bao giờ áp được cho khách cũ và khuyến mãi
+ * năm đầu bị gia hạn vĩnh viễn với giá khuyến mãi. Không tra được giá hiện hành (sản phẩm
+ * ngừng bán, chu kỳ bị tắt) thì lùi về giá cũ để vẫn xuất được hoá đơn.
+ */
+ private function renewalPrice(OrderItem $orderItem): int
+ {
+ $legacyPrice = (int) $orderItem->price;
+
+ if (! config('billing.renewal_uses_current_price', true) || ! $orderItem->product_id || ! $orderItem->billing_cycle) {
+ return $legacyPrice;
+ }
+
+ try {
+ $current = app(ServiceRepository::class)->getPricing($orderItem->product_id, $orderItem->billing_cycle);
+ } catch (\Throwable $e) {
+ Log::warning('service:expiry | không tra được giá hiện hành, dùng giá cũ', [
+ 'order_item_id' => $orderItem->id,
+ 'error' => $e->getMessage(),
+ ]);
+ return $legacyPrice;
+ }
+
+ if ($current === null || (int) $current <= 0) {
+ Log::info('service:expiry | không có giá hiện hành cho chu kỳ này, dùng giá cũ', [
+ 'order_item_id' => $orderItem->id,
+ 'billing_cycle' => $orderItem->billing_cycle,
+ ]);
+ return $legacyPrice;
+ }
+
+ if ((int) $current !== $legacyPrice) {
+ Log::info('service:expiry | giá gia hạn đổi theo bảng giá hiện hành', [
+ 'order_item_id' => $orderItem->id,
+ 'old_price' => $legacyPrice,
+ 'new_price' => (int) $current,
+ ]);
+ }
+
+ return (int) $current;
+ }
+
+ /**
+ * Trả tài nguyên về hạ tầng khi dịch vụ đã chết hẳn. Trước đây terminate chỉ đổi status,
+ * VM vẫn nằm trên vCenter và hosting vẫn chiếm quota — công ty gánh chi phí cho dịch vụ
+ * không ai trả tiền.
+ *
+ * ponytail: hosting chỉ Suspend vì Http\Services\Hosting chưa có API xoá tài khoản —
+ * chỉ bọc delete khi nhà cung cấp mở endpoint, đừng đoán.
+ */
+ private function reclaimResources(Service $service): void
+ {
+ if ($service->vm_id) {
+ dispatch(new RemoveVirtualMachine((int) $service->vm_id));
+ Log::info('Terminate: dispatched VM removal', ['service_id' => $service->id, 'vm_id' => $service->vm_id]);
+ $this->line(" [reclaim] VM #{$service->vm_id} đã xếp hàng xoá.");
+ return;
+ }
+
+ if ($service->orderItem?->product?->product_type === 'hosting') {
+ Log::warning('Terminate: hosting account cần thu hồi thủ công (chưa có API xoá)', [
+ 'service_id' => $service->id,
+ 'server_name' => $service->server_name,
+ ]);
+ $this->line(" [reclaim] Hosting {$service->server_name}: cần thu hồi thủ công.");
+ }
+ }
+
private function createRenewalInvoiceIfMissing(Service $service, bool $dryRun = false): void
{
// Fresh DB query via invoice_id FK — does NOT rely on stale eager-loaded relation.
@@ -133,14 +209,35 @@ class ServiceExpiry extends Command
}
$orderItem = $service->orderItem;
- if (!$orderItem || (int) $orderItem->price <= 0) {
- Log::warning('service:expiry | Cannot create renewal invoice: no order item or zero price', ['service_id' => $service->id]);
+ if (!$orderItem) {
+ Log::warning('service:expiry | Cannot create renewal invoice: no order item', ['service_id' => $service->id]);
return;
}
- $price = (int) $orderItem->price;
+ // Dịch vụ import từ WHMCS có services.invoice_id = NULL dù hoá đơn gia hạn của nó đã
+ // được đồng bộ sang — chỉ dựa vào invoice_id sẽ xuất trùng một hoá đơn nữa cho cùng kỳ.
+ // Soát thêm theo order: đơn đã có hoá đơn đang mở thì coi như đã xuất.
+ $openInvoiceOnOrder = Invoice::where('order_id', $orderItem->order_id)
+ ->whereIn('status', [InvoiceStatus::UNPAID->value, InvoiceStatus::OVERDUE->value])
+ ->first();
+
+ if ($openInvoiceOnOrder) {
+ // Trỏ service về hoá đơn đang mở để lần chạy sau đi vào nhánh nhanh ở trên.
+ if ((int) $service->invoice_id !== (int) $openInvoiceOnOrder->id) {
+ $service->update(['invoice_id' => $openInvoiceOnOrder->id]);
+ }
+ $this->line(" [skip] Service #{$service->id}: đơn #{$orderItem->order_id} đã có hoá đơn {$openInvoiceOnOrder->invoice_number} đang mở.");
+ return;
+ }
+
+ $price = $this->renewalPrice($orderItem);
$months = $orderItem->months ?? 1;
+ if ($price <= 0) {
+ Log::warning('service:expiry | Cannot create renewal invoice: zero price', ['service_id' => $service->id]);
+ return;
+ }
+
if ($dryRun) {
$this->line(" [dry-run:invoice] Would create renewal invoice for Service #{$service->id} on Order #{$orderItem->order_id}, amount " . number_format($price) . ' VND.');
return;
@@ -163,7 +260,7 @@ class ServiceExpiry extends Command
'status' => InvoiceStatus::UNPAID->value,
'due_date' => $service->expires_at && $service->expires_at->isFuture()
? $service->expires_at->toDateString()
- : now()->addDays(7)->toDateString(),
+ : now()->addDays((int) config('billing.renewal_due_days', 7))->toDateString(),
'billing_period_start' => $periodStart->toDateString(),
'billing_period_end' => $periodEnd->toDateString(),
]);
diff --git a/app/Console/Kernel.php b/app/Console/Kernel.php
index 72a4dc0..61078c0 100644
--- a/app/Console/Kernel.php
+++ b/app/Console/Kernel.php
@@ -55,6 +55,12 @@ class Kernel extends ConsoleKernel
->timezone("Asia/Ho_Chi_Minh")
->dailyAt("00:10")
->withoutOverlapping();
+
+ /* Nhắc thanh toán trước/đúng/sau hạn theo billing.reminder_offsets */
+ $schedule->command('invoices:send-reminders')
+ ->timezone("Asia/Ho_Chi_Minh")
+ ->dailyAt("08:00")
+ ->withoutOverlapping();
}
/**
diff --git a/app/Http/Controllers/Api/Admin/EmailNotificationTemplateController.php b/app/Http/Controllers/Api/Admin/EmailNotificationTemplateController.php
deleted file mode 100644
index 244f590..0000000
--- a/app/Http/Controllers/Api/Admin/EmailNotificationTemplateController.php
+++ /dev/null
@@ -1,127 +0,0 @@
-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']],
- ];
- }
-}
diff --git a/app/Http/Controllers/Api/Admin/EmailTemplate/EmailTemplateController.php b/app/Http/Controllers/Api/Admin/EmailTemplate/EmailTemplateController.php
index 5ee13de..0864802 100644
--- a/app/Http/Controllers/Api/Admin/EmailTemplate/EmailTemplateController.php
+++ b/app/Http/Controllers/Api/Admin/EmailTemplate/EmailTemplateController.php
@@ -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(),
diff --git a/app/Http/Controllers/Api/Admin/Invoice/InvoiceController.php b/app/Http/Controllers/Api/Admin/Invoice/InvoiceController.php
index 6a75b7e..b99e60b 100644
--- a/app/Http/Controllers/Api/Admin/Invoice/InvoiceController.php
+++ b/app/Http/Controllers/Api/Admin/Invoice/InvoiceController.php
@@ -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
diff --git a/app/Http/Controllers/Api/Admin/Setting/SettingController.php b/app/Http/Controllers/Api/Admin/Setting/SettingController.php
index 6869cc6..7e9a34e 100644
--- a/app/Http/Controllers/Api/Admin/Setting/SettingController.php
+++ b/app/Http/Controllers/Api/Admin/Setting/SettingController.php
@@ -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.
diff --git a/app/Http/Controllers/Api/Admin/Transaction/AdminTransactionController.php b/app/Http/Controllers/Api/Admin/Transaction/AdminTransactionController.php
index e051eb1..ad9a8bf 100644
--- a/app/Http/Controllers/Api/Admin/Transaction/AdminTransactionController.php
+++ b/app/Http/Controllers/Api/Admin/Transaction/AdminTransactionController.php
@@ -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',
diff --git a/app/Http/Controllers/Api/Client/ClientController.php b/app/Http/Controllers/Api/Client/ClientController.php
index e98889c..84e6dbc 100644
--- a/app/Http/Controllers/Api/Client/ClientController.php
+++ b/app/Http/Controllers/Api/Client/ClientController.php
@@ -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(),
diff --git a/app/Http/Controllers/Api/Service/OrderController.php b/app/Http/Controllers/Api/Service/OrderController.php
deleted file mode 100644
index c3fad65..0000000
--- a/app/Http/Controllers/Api/Service/OrderController.php
+++ /dev/null
@@ -1,917 +0,0 @@
-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);
- }
-}
diff --git a/app/Http/Controllers/Api/Service/OrderControllerRefactor.php b/app/Http/Controllers/Api/Service/OrderControllerRefactor.php
index cad5df2..9ca978c 100644
--- a/app/Http/Controllers/Api/Service/OrderControllerRefactor.php
+++ b/app/Http/Controllers/Api/Service/OrderControllerRefactor.php
@@ -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) {
diff --git a/app/Http/Controllers/admin/CustomerController.php b/app/Http/Controllers/admin/CustomerController.php
index 17a367f..0ee1af8 100644
--- a/app/Http/Controllers/admin/CustomerController.php
+++ b/app/Http/Controllers/admin/CustomerController.php
@@ -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'));
diff --git a/app/Http/Kernel.php b/app/Http/Kernel.php
index e942bd7..73e416c 100644
--- a/app/Http/Kernel.php
+++ b/app/Http/Kernel.php
@@ -23,6 +23,27 @@ class Kernel extends HttpKernel
\Illuminate\Http\Middleware\HandleCors::class
];
+ /**
+ * Mặc định của Laravel + ClientAccessTokenCheck đứng trước ThrottleRequests: nó gán IP thật
+ * của khách (từ payload x-access-token) nên throttle phải chạy sau.
+ *
+ * @var array
+ */
+ protected $middlewarePriority = [
+ \Illuminate\Foundation\Http\Middleware\HandlePrecognitiveRequests::class,
+ \Illuminate\Cookie\Middleware\EncryptCookies::class,
+ \Illuminate\Cookie\Middleware\AddQueuedCookiesToResponse::class,
+ \Illuminate\Session\Middleware\StartSession::class,
+ \Illuminate\View\Middleware\ShareErrorsFromSession::class,
+ \Illuminate\Contracts\Auth\Middleware\AuthenticatesRequests::class,
+ \App\Http\Middleware\ClientAccessTokenCheck::class,
+ \Illuminate\Routing\Middleware\ThrottleRequests::class,
+ \Illuminate\Routing\Middleware\ThrottleRequestsWithRedis::class,
+ \Illuminate\Contracts\Session\Middleware\AuthenticatesSessions::class,
+ \Illuminate\Routing\Middleware\SubstituteBindings::class,
+ \Illuminate\Auth\Middleware\Authorize::class,
+ ];
+
/**
* The application's route middleware groups.
*
diff --git a/app/Http/Middleware/ApiAuthCheck.php b/app/Http/Middleware/ApiAuthCheck.php
index 1baef2f..0788b7d 100644
--- a/app/Http/Middleware/ApiAuthCheck.php
+++ b/app/Http/Middleware/ApiAuthCheck.php
@@ -27,6 +27,19 @@ class ApiAuthCheck
], 401);
}
+ // Token cấp trước lần đổi mật khẩu gần nhất → vô hiệu.
+ // ponytail: token cũ chưa có claim `pv` vẫn được nhận cho tới khi hết JWT_TTL (chỉ lúc mới deploy)
+ if ($checkGuard === 'client' && Auth::guard('client')->getToken()) {
+ $pv = Auth::guard('client')->payload()->get('pv');
+ if ($pv !== null && !hash_equals(Auth::guard('client')->user()->passwordVersion(), (string) $pv)) {
+ return response()->json([
+ 'error' => true,
+ 'message' => 'Phiên đăng nhập đã hết hạn, vui lòng đăng nhập lại.',
+ 'code' => 401
+ ], 401);
+ }
+ }
+
// Ensure request user is set for the controller
$request->setUserResolver(function () use ($checkGuard) {
return Auth::guard($checkGuard)->user();
diff --git a/app/Http/Middleware/ClientAccessTokenCheck.php b/app/Http/Middleware/ClientAccessTokenCheck.php
index feb546b..4d49433 100644
--- a/app/Http/Middleware/ClientAccessTokenCheck.php
+++ b/app/Http/Middleware/ClientAccessTokenCheck.php
@@ -9,16 +9,28 @@ class ClientAccessTokenCheck
{
/**
* Verify the `x-access-token` header contains an AES-256-CBC ciphertext
- * (base64(iv . ciphertext)) of the shared secret, encrypted with the
- * openssl key configured in services.client_api.key. Blocks callers
- * that don't hold the shared key (external scraping/direct API calls).
+ * (base64(iv . ciphertext)) of JSON {"s": , "t": , "ip"?: },
+ * encrypted with the openssl key configured in services.client_api.key. Blocks callers
+ * that don't hold the shared key (external scraping/direct API calls);
+ * the timestamp caps replay of a captured header to MAX_SKEW_SECONDS.
+ *
+ * portal-client gọi API từ Next server nên REMOTE_ADDR luôn là IP của Next. IP thật của khách
+ * nằm trong payload đã mã hoá (không giả mạo được như X-Forwarded-For) → gán lại REMOTE_ADDR
+ * để throttle/log dùng đúng IP. Middleware này được ưu tiên chạy trước ThrottleRequests (Kernel).
*/
+ private const MAX_SKEW_SECONDS = 300;
+
public function handle(Request $request, Closure $next)
{
- $token = $request->header('x-access-token', '');
$key = config('services.client_api.key');
+ $secret = config('services.client_api.secret');
+ $payload = $key && $secret ? $this->decode($request->header('x-access-token', ''), $key) : null;
- if (!$key || !$this->isValid($token, $key)) {
+ if (!$payload
+ || !is_string($payload['s'] ?? null)
+ || !hash_equals($secret, $payload['s'])
+ || !is_int($payload['t'] ?? null)
+ || abs(time() - $payload['t']) > self::MAX_SKEW_SECONDS) {
return response()->json([
'error' => true,
'message' => 'Invalid access token',
@@ -26,25 +38,23 @@ class ClientAccessTokenCheck
], 403);
}
+ if (filter_var($payload['ip'] ?? null, FILTER_VALIDATE_IP)) {
+ $request->server->set('REMOTE_ADDR', $payload['ip']);
+ }
+
return $next($request);
}
- private function isValid(string $token, string $key): bool
+ private function decode(string $token, string $key): ?array
{
$raw = base64_decode($token, true);
if ($raw === false || strlen($raw) <= 16) {
- return false;
+ return null;
}
- $iv = substr($raw, 0, 16);
- $ciphertext = substr($raw, 16);
+ $plaintext = openssl_decrypt(substr($raw, 16), 'aes-256-cbc', hex2bin($key), OPENSSL_RAW_DATA, substr($raw, 0, 16));
+ $payload = $plaintext === false ? null : json_decode($plaintext, true);
- $plaintext = openssl_decrypt($ciphertext, 'aes-256-cbc', hex2bin($key), OPENSSL_RAW_DATA, $iv);
-
- if ($plaintext === false) {
- return false;
- }
-
- return hash_equals(config('services.client_api.secret'), $plaintext);
+ return is_array($payload) ? $payload : null;
}
}
diff --git a/app/Http/Requests/Api/Order/PayOrderRequest.php b/app/Http/Requests/Api/Order/PayOrderRequest.php
index 69b4ece..66bc8e7 100644
--- a/app/Http/Requests/Api/Order/PayOrderRequest.php
+++ b/app/Http/Requests/Api/Order/PayOrderRequest.php
@@ -11,10 +11,22 @@ class PayOrderRequest extends FormRequest
return true;
}
+ /**
+ * Endpoint này luôn trừ số dư ví (OrderService::pay → payWithBalance) — không có cổng
+ * thanh toán nào được gọi. Nhận vnpay/momo/bank_transfer ở đây chỉ khiến hoá đơn ghi sai
+ * nguồn tiền và kế toán đối soát nhầm. Cổng ngoài, khi làm, phải có route riêng.
+ */
public function rules(): array
{
return [
- 'payment_method' => 'required|in:credit,vnpay,momo,bank_transfer'
+ 'payment_method' => 'required|in:credit,balance'
+ ];
+ }
+
+ public function messages(): array
+ {
+ return [
+ 'payment_method.in' => 'Đơn hàng chỉ thanh toán được bằng số dư tài khoản. Vui lòng nạp tiền trước nếu số dư chưa đủ.',
];
}
}
diff --git a/app/Http/Resources/Api/Admin/InvoiceResource.php b/app/Http/Resources/Api/Admin/InvoiceResource.php
index 2a476ca..c840fc5 100644
--- a/app/Http/Resources/Api/Admin/InvoiceResource.php
+++ b/app/Http/Resources/Api/Admin/InvoiceResource.php
@@ -22,9 +22,14 @@ class InvoiceResource extends JsonResource
'discount_amount' => $this->discount_amount,
'tax' => $this->tax,
'total' => $this->total,
+ // Công nợ: đã thu bao nhiêu / còn thiếu bao nhiêu. Kế toán cần thấy ngay trên
+ // danh sách thay vì phải suy từ số dư ví của khách.
+ 'amount_paid' => (int) $this->amount_paid,
+ 'outstanding' => \App\Services\InvoicePaymentService::outstanding($this->resource),
'status' => $this->status,
'due_date' => $this->due_date,
'paid_at' => $this->paid_at,
+ 'reminder_step' => (int) $this->reminder_step,
'created_at' => $this->created_at,
'updated_at' => $this->updated_at,
];
diff --git a/app/Http/Resources/Api/Admin/OrderResource.php b/app/Http/Resources/Api/Admin/OrderResource.php
index 26d31d7..78bbd04 100644
--- a/app/Http/Resources/Api/Admin/OrderResource.php
+++ b/app/Http/Resources/Api/Admin/OrderResource.php
@@ -56,10 +56,13 @@ class OrderResource extends JsonResource
'discount_amount' => $invoice->discount_amount,
'tax' => $invoice->tax,
'total' => $invoice->total,
+ 'amount_paid' => (int) $invoice->amount_paid,
+ 'outstanding' => \App\Services\InvoicePaymentService::outstanding($invoice),
'status' => $invoice->status,
'date' => $invoice->created_at,
'due_date' => $invoice->due_date,
'date_paid' => $invoice->paid_at,
+ 'reminder_step' => (int) $invoice->reminder_step,
'billing_period_start' => $invoice->billing_period_start,
'billing_period_end' => $invoice->billing_period_end,
// Which service(s)/product(s) this invoice actually bills for — without this,
diff --git a/app/Http/Resources/Api/Client/ClientResource.php b/app/Http/Resources/Api/Client/ClientResource.php
index 91d0d11..bedbb8c 100644
--- a/app/Http/Resources/Api/Client/ClientResource.php
+++ b/app/Http/Resources/Api/Client/ClientResource.php
@@ -4,6 +4,8 @@ namespace App\Http\Resources\Api\Client;
use Illuminate\Http\Request;
use Illuminate\Http\Resources\Json\JsonResource;
+use Illuminate\Support\Facades\URL;
+use Illuminate\Support\Str;
class ClientResource extends JsonResource
{
@@ -31,9 +33,9 @@ class ClientResource extends JsonResource
'date' => $this->created_at ? $this->created_at->format('Y-m-d H:i:s') : null,
'company' => $this->company ?? '',
'avatar' => $this->avatar,
- 'cmnd_before' => $this->front_identity_number,
- 'cmnd_after' => $this->back_identity_number,
- 'cmnd' => $this->identity_number,
+ 'cmnd_before' => $this->cccdUrl('front', $this->front_identity_number),
+ 'cmnd_after' => $this->cccdUrl('back', $this->back_identity_number),
+ 'cmnd' => $this->identity_number ? Str::mask($this->identity_number, '*', 0, -4) : null,
'fb_link' => null,
'active_2fa' => 0,
'off_mail_marketing' => $this->off_mail_marketing,
@@ -57,4 +59,18 @@ class ClientResource extends JsonResource
]
];
}
+
+ /**
+ * Ảnh CCCD trên disk private → URL ký tương đối (/v1/client/cccd/...), hết hạn 60 phút.
+ */
+ private function cccdUrl(string $side, ?string $path): string
+ {
+ if (!$path) {
+ return '';
+ }
+
+ // Giá trị import từ portal cũ (/uploads/... trên host cũ) cũng chỉ trả URL ký — không lộ đường dẫn gốc;
+ // route trả 404 cho chúng vì file không có trên disk private. Vẫn khác rỗng để hasCccd giữ nguyên hành vi.
+ return URL::temporarySignedRoute('api.client.cccd', now()->addHour(), ['customer' => $this->id, 'side' => $side], false);
+ }
}
diff --git a/app/Http/Resources/Api/Service/Order/InvoiceDetailResource.php b/app/Http/Resources/Api/Service/Order/InvoiceDetailResource.php
index dddf373..f9b9763 100644
--- a/app/Http/Resources/Api/Service/Order/InvoiceDetailResource.php
+++ b/app/Http/Resources/Api/Service/Order/InvoiceDetailResource.php
@@ -21,6 +21,9 @@ class InvoiceDetailResource extends JsonResource
'discount_amount' => (float) ($this->discount_amount ?? 0),
'tax' => (float) $this->tax,
'total' => (float) $this->total,
+ // Công nợ: đã thu / còn thiếu. Dịch vụ chỉ kích hoạt khi outstanding = 0.
+ 'amount_paid' => (float) $this->amount_paid,
+ 'outstanding' => (float) \App\Services\InvoicePaymentService::outstanding($this->resource),
'payment_method' => $this->resolvePaymentMethod($this->resource),
'auto_renew' => (bool) $this->auto_renew,
'created_at' => $this->created_at?->toISOString(),
diff --git a/app/Http/Resources/Api/Service/Order/InvoiceSummaryResource.php b/app/Http/Resources/Api/Service/Order/InvoiceSummaryResource.php
index 7c3d226..df0a588 100644
--- a/app/Http/Resources/Api/Service/Order/InvoiceSummaryResource.php
+++ b/app/Http/Resources/Api/Service/Order/InvoiceSummaryResource.php
@@ -15,6 +15,9 @@ class InvoiceSummaryResource extends JsonResource
'invoice_number' => $this->invoice_number,
'service_name' => $this->order?->orderItems->first()?->product?->name ?? ('Đơn hàng ' . $this->order?->order_num),
'total' => (float) $this->total,
+ // Khách trả một phần thì hoá đơn vẫn chưa thanh toán — phải thấy được còn thiếu bao nhiêu.
+ 'amount_paid' => (float) $this->amount_paid,
+ 'outstanding' => (float) \App\Services\InvoicePaymentService::outstanding($this->resource),
'status' => $this->status,
'status_text' => InvoiceStatus::tryFrom((int) $this->status)?->label() ?? '-',
'created_at' => $this->created_at?->toISOString(),
diff --git a/app/Integrations/Whmcs/DTOs/InvoiceData.php b/app/Integrations/Whmcs/DTOs/InvoiceData.php
index 8b9aa07..4427953 100644
--- a/app/Integrations/Whmcs/DTOs/InvoiceData.php
+++ b/app/Integrations/Whmcs/DTOs/InvoiceData.php
@@ -15,6 +15,8 @@ class InvoiceData
public readonly int $discountAmount,
public readonly int $tax,
public readonly int $total,
+ /** Số đã thu; hoá đơn WHMCS Paid coi như thu đủ, còn lại lấy phần credit đã áp. */
+ public readonly int $amountPaid,
public readonly int $status,
public readonly ?string $paymentMethod,
public readonly ?string $dueDate,
diff --git a/app/Integrations/Whmcs/Transformers/InvoiceTransformer.php b/app/Integrations/Whmcs/Transformers/InvoiceTransformer.php
index ec437ea..db86024 100644
--- a/app/Integrations/Whmcs/Transformers/InvoiceTransformer.php
+++ b/app/Integrations/Whmcs/Transformers/InvoiceTransformer.php
@@ -24,6 +24,8 @@ class InvoiceTransformer
$legacyId = (int) $row['id'];
$dueDate = $this->nullableDate($row['duedate'] ?? null);
+ $status = $this->mapStatus($row['status'], $dueDate);
+ $total = (int) round((float) $row['total']);
$whmcsOrderId = $this->extractor->orderIdFor($legacyId);
$rawItems = $this->extractor->items($legacyId);
@@ -46,8 +48,11 @@ class InvoiceTransformer
subTotal: (int) round((float) $row['subtotal']),
discountAmount: $discountAmount,
tax: (int) round((float) $row['tax'] + (float) $row['tax2']),
- total: (int) round((float) $row['total']),
- status: $this->mapStatus($row['status'], $dueDate),
+ total: $total,
+ // Không có amount_paid thì mọi hoá đơn WHMCS đã thanh toán sẽ hiện "chưa thu"
+ // và outstanding = total trên toàn bộ dữ liệu import.
+ amountPaid: $status === 2 ? $total : (int) round((float) ($row['credit'] ?? 0)),
+ status: $status,
paymentMethod: $row['paymentmethod'] ?: null,
dueDate: $dueDate,
paidAt: $this->nullableDate($row['datepaid'] ?? null),
diff --git a/app/Integrations/Whmcs/Writers/InvoiceWriter.php b/app/Integrations/Whmcs/Writers/InvoiceWriter.php
index 4390fcd..274aabd 100644
--- a/app/Integrations/Whmcs/Writers/InvoiceWriter.php
+++ b/app/Integrations/Whmcs/Writers/InvoiceWriter.php
@@ -29,6 +29,7 @@ class InvoiceWriter
'discount_amount' => $data->discountAmount,
'tax' => $data->tax,
'total' => $data->total,
+ 'amount_paid' => $data->amountPaid,
'status' => $data->status,
'payment_method' => $data->paymentMethod,
'due_date' => $data->dueDate,
diff --git a/app/Jobs/Mail/SendInformationVirtualMachine.php b/app/Jobs/Mail/SendInformationVirtualMachine.php
index 60d2401..b784ca3 100644
--- a/app/Jobs/Mail/SendInformationVirtualMachine.php
+++ b/app/Jobs/Mail/SendInformationVirtualMachine.php
@@ -3,7 +3,7 @@
namespace App\Jobs\Mail;
use App\Http\Services\Telegram;
-use App\Mail\VmInfoMail;
+use App\Mail\Customer\VmCredentials;
use App\Models\Invoice;
use App\Services\InvoicePdfService;
use Carbon\Carbon;
@@ -56,7 +56,7 @@ class SendInformationVirtualMachine implements ShouldQueue
}
}
- $email = new VmInfoMail($this->data, $invoicePdf, $invoicePdfFileName);
+ $email = new VmCredentials($this->data, $invoicePdf, $invoicePdfFileName);
Mail::to($this->customer->email)->send($email);
diff --git a/app/Jobs/Payment/GetTransactionPayment.php b/app/Jobs/Payment/GetTransactionPayment.php
index 366be2c..df92152 100644
--- a/app/Jobs/Payment/GetTransactionPayment.php
+++ b/app/Jobs/Payment/GetTransactionPayment.php
@@ -2,11 +2,13 @@
namespace App\Jobs\Payment;
+use App\Enums\TransactionStatus;
use App\Events\Notification_Expiry_Time;
use App\Events\Notification_Pay_Success;
use App\Http\Services\MBBank;
use App\Models\Customer;
use App\Models\Transaction;
+use Illuminate\Support\Str;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Bus\Dispatchable;
@@ -60,14 +62,26 @@ class GetTransactionPayment implements ShouldQueue
// firstOrCreate is atomic against the unique(customer_id, trans, type) index —
// a duplicate bank-statement poll (job retried, matched twice) cannot credit twice.
+ $amount = (int) $item['creditAmount'];
+
+ // type/status/uuid/public_code phải set đủ: unique index là
+ // (customer_id, trans, type) nên type NULL làm chống trùng hụt một nửa,
+ // và kế toán không lọc được dòng tiền vào theo loại khi lên báo cáo.
$created = Transaction::firstOrCreate(
- ['trans' => $transaction, 'customer_id' => $this->user],
- ['amount' => (int) $item['creditAmount']]
+ ['trans' => $transaction, 'customer_id' => $this->user, 'type' => 'auto'],
+ [
+ 'uuid' => (string) Str::uuid(),
+ 'public_code' => 'DEP-AUTO-' . now()->format('Ymd') . '-' . strtoupper(Str::random(6)),
+ 'amount' => $amount,
+ 'received_amount' => $amount,
+ 'status' => TransactionStatus::COMPLETED->value,
+ 'note' => 'Tự động khớp sao kê MB Bank',
+ ]
);
if ($created->wasRecentlyCreated) {
- Customer::find($this->user)->increment('credit', (int)$item['creditAmount']);
- Customer::find($this->user)->increment('total', (int)$item['creditAmount']);
+ Customer::find($this->user)->increment('credit', Customer::creditFor($amount));
+ Customer::find($this->user)->increment('total', $amount);
$message = number_format($item['creditAmount'], 0, ',', '.') . "đ vào tài khoản";
$status = 1;
diff --git a/app/Mail/Customer/PasswordResetLink.php b/app/Mail/Customer/PasswordResetLink.php
new file mode 100644
index 0000000..dd39cf9
--- /dev/null
+++ b/app/Mail/Customer/PasswordResetLink.php
@@ -0,0 +1,40 @@
+renderFromTemplate('customer_password_reset_link', $this->vars())['subject'],
+ );
+ }
+
+ public function content(): Content
+ {
+ return new Content(
+ htmlString: $this->renderFromTemplate('customer_password_reset_link', $this->vars())['html'],
+ );
+ }
+
+ private function vars(): array
+ {
+ return [
+ 'full_name' => $this->data['fullname'] ?? 'Quý khách',
+ 'reset_url' => $this->data['reset_url'] ?? '',
+ 'expire_minutes' => (string) config('auth.passwords.users.expire', 60),
+ ];
+ }
+}
diff --git a/app/Mail/Customer/VmCredentials.php b/app/Mail/Customer/VmCredentials.php
new file mode 100644
index 0000000..6b64b61
--- /dev/null
+++ b/app/Mail/Customer/VmCredentials.php
@@ -0,0 +1,95 @@
+renderFromTemplate('vm_credentials', $this->vars())['subject'],
+ );
+ }
+
+ public function content(): Content
+ {
+ return new Content(
+ htmlString: $this->renderFromTemplate('vm_credentials', $this->vars())['html'],
+ );
+ }
+
+ public function attachments(): array
+ {
+ if ($this->invoicePdf !== null && $this->invoicePdfFileName !== null) {
+ return [
+ Attachment::fromData(fn () => $this->invoicePdf, $this->invoicePdfFileName)
+ ->withMime('application/pdf'),
+ ];
+ }
+
+ return [];
+ }
+
+ private function payload(): array
+ {
+ try {
+ $setting = Setting::where('key', 'setting')->first();
+ $settingValue = $setting ? json_decode((string) $setting->value, true) : [];
+ } catch (\Throwable) {
+ $settingValue = [];
+ }
+
+ return array_merge([
+ 'support_email' => $settingValue['email'] ?? config('mail.support_email', 'support@thegioiso.com'),
+ 'support_phone' => $settingValue['hotline'] ?? ($settingValue['phone'] ?? config('mail.support_phone', '1900.xxxx')),
+ 'login_url' => rtrim(env('FRONTEND_URL', config('app.url')), '/') . '/dang-nhap',
+ ], $this->data);
+ }
+
+ private function vars(): array
+ {
+ $data = $this->payload();
+
+ $row = fn (string $label, ?string $value) => $value
+ ? "| {$label} | {$value} |
"
+ : '';
+
+ return [
+ 'app_name' => $data['company_name'] ?? config('app.name', 'The Gioi So'),
+ 'full_name' => $data['client_name'] ?? 'Quý khách',
+ 'product_name' => $data['product_name'] ?? 'Cloud VPS',
+ 'product_billing_row' => $row('Chu kỳ thanh toán:', $data['product_billing'] ?? null),
+ 'product_price_row' => $row('Giá dịch vụ:', !empty($data['product_price']) ? $data['product_price'] . ' VNĐ' : null),
+ 'os_name_row' => $row('Hệ điều hành:', $data['os_name'] ?? null),
+ 'service_host' => $data['service_host'] ?? '',
+ 'service_username' => $data['service_username'] ?? '',
+ 'service_password' => $data['service_password'] ?? '',
+ 'service_port_row' => $row('Cổng kết nối:', $data['service_port'] ?? null),
+ 'service_due_exp_row' => $row('Hạn sử dụng:', $data['service_due_exp'] ?? null),
+ 'login_url' => $data['login_url'] ?? rtrim(env('FRONTEND_URL', config('app.url')), '/') . '/dang-nhap',
+ 'support_email' => $data['support_email'] ?? config('mail.support_email', 'support@thegioiso.com'),
+ 'support_phone' => $data['support_phone'] ?? config('mail.support_phone', '1900.xxxx'),
+ ];
+ }
+}
diff --git a/app/Mail/InvoiceNotificationMail.php b/app/Mail/InvoiceNotificationMail.php
index c5b970f..1aee16f 100644
--- a/app/Mail/InvoiceNotificationMail.php
+++ b/app/Mail/InvoiceNotificationMail.php
@@ -2,21 +2,28 @@
namespace App\Mail;
-use App\Models\EmailNotificationTemplate;
+use App\Enums\Invoice\InvoiceStatus;
+use App\Mail\Concerns\RequiresDbTemplate;
use App\Models\Invoice;
-use App\Services\EmailNotificationTemplateRenderer;
+use App\Services\InvoicePaymentService;
+use Carbon\Carbon;
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;
/**
* KHÔNG implement ShouldQueue — gọi từ trong queued job, gửi đồng bộ.
+ *
+ * Render từ tbl.template (scenario `invoice_notification`) như mọi mailable khác. Trước đây
+ * đi qua bảng email_notification_templates riêng và rơi về blade khi không có template —
+ * hai hệ template song song cho cùng một email, admin sửa một nơi thì nơi kia vẫn nguyên.
*/
class InvoiceNotificationMail extends Mailable
{
+ use RequiresDbTemplate;
+
public function __construct(
public readonly Invoice $invoice,
public readonly Collection $invoiceAttachments,
@@ -31,50 +38,15 @@ class InvoiceNotificationMail extends Mailable
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)
- : $fallbackSubject;
-
- return new Envelope(subject: $subject);
+ return new Envelope(
+ subject: $this->renderFromTemplate('invoice_notification', $this->vars())['subject'],
+ );
}
public function content(): Content
{
- $tpl = $this->activeTemplate();
-
- if ($tpl) {
- try {
- $renderer = app(EmailNotificationTemplateRenderer::class);
- $html = $renderer->render(
- $tpl,
- $this->invoice->load(['order.orderItems.product', 'customer', 'attachments']),
- $this->companyName
- );
- return new Content(htmlString: $html);
- } catch (\Throwable $e) {
- Log::warning('InvoiceNotificationMail: renderer failed, falling back to blade', [
- 'template_id' => $tpl->id,
- 'error' => $e->getMessage(),
- ]);
- }
- }
-
return new Content(
- view: 'emails.invoice-notification',
- with: [
- 'invoice' => $this->invoice,
- 'attachments' => $this->invoiceAttachments,
- 'customerName' => $this->customerName,
- 'bodyMessage' => $this->message,
- 'companyName' => $this->companyName,
- 'type' => $this->type,
- 'paymentUrl' => $this->paymentUrl,
- ]
+ htmlString: $this->renderFromTemplate('invoice_notification', $this->vars())['html'],
);
}
@@ -90,19 +62,120 @@ class InvoiceNotificationMail extends Mailable
return [];
}
- private function activeTemplate(): ?EmailNotificationTemplate
+ private function vars(): array
{
- return EmailNotificationTemplate::where('type', 'invoice_notification')
- ->where('is_active', true)
- ->first();
+ $isRenewal = $this->type === 'renewal';
+
+ return [
+ 'title' => $isRenewal ? 'Thông báo gia hạn cước' : 'Thông báo hóa đơn',
+ 'company_name' => $this->companyName,
+ 'customer_name' => $this->customerName,
+ 'body_message' => $this->message,
+ 'invoice_number' => $this->invoice->invoice_number ?? $this->invoice->order?->order_num ?? '',
+ 'created_date' => $this->formatDate($this->invoice->date ?? $this->invoice->created_at),
+ 'due_date' => $this->formatDate($this->invoice->due_date),
+ 'total' => number_format((int) $this->invoice->total, 0, ',', '.'),
+ 'status_label' => $this->statusLabel(),
+ 'outstanding_row' => $this->outstandingRow(),
+ 'intro_note' => $isRenewal
+ ? '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.'
+ : '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.',
+ 'pay_button' => $this->payButton(),
+ 'attachments_block' => $this->attachmentsBlock(),
+ 'note_block' => $this->noteBlock(),
+ ];
}
- private function resolveSubject(string $subject): string
+ private function formatDate(mixed $value): string
{
- return str_replace(
- ['{{invoice_number}}', '{{company_name}}'],
- [$this->invoice->invoice_number ?? '', $this->companyName],
- $subject
- );
+ 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::parse($value)->format('d/m/Y');
+ } catch (\Throwable) {
+ return '—';
+ }
+ }
+
+ private function statusLabel(): string
+ {
+ return match ((int) $this->invoice->status) {
+ InvoiceStatus::PAID->value => 'Đã thanh toán',
+ InvoiceStatus::UNPAID->value => 'Chưa thanh toán',
+ InvoiceStatus::OVERDUE->value => 'Quá hạn',
+ InvoiceStatus::REFUNDED->value => 'Đã hoàn tiền',
+ default => 'Đã hủy',
+ };
+ }
+
+ /** Chỉ hiện khi khách đã trả được một phần nhưng chưa đủ. */
+ private function outstandingRow(): string
+ {
+ $paid = (int) $this->invoice->amount_paid;
+ $outstanding = InvoicePaymentService::outstanding($this->invoice);
+
+ if ($paid <= 0 || $outstanding <= 0) {
+ return '';
+ }
+
+ return '| Đã thanh toán: | '
+ . number_format($paid, 0, ',', '.') . ' VNĐ |
'
+ . '| Còn thiếu: | '
+ . number_format($outstanding, 0, ',', '.') . ' VNĐ |
';
+ }
+
+ private function payButton(): string
+ {
+ if (empty($this->paymentUrl)) {
+ return '';
+ }
+
+ $url = htmlspecialchars($this->paymentUrl, ENT_QUOTES, 'UTF-8');
+
+ return ''
+ . 'Hoặc sao chép liên kết thanh toán: ' . $url . '
';
+ }
+
+ private function attachmentsBlock(): string
+ {
+ if ($this->invoiceAttachments->isEmpty()) {
+ return '';
+ }
+
+ $items = $this->invoiceAttachments
+ ->map(fn($file) => ''
+ . htmlspecialchars((string) $file->filename, ENT_QUOTES, 'UTF-8')
+ . '')
+ ->implode('');
+
+ return '';
+ }
+
+ private function noteBlock(): string
+ {
+ if (empty($this->invoice->note)) {
+ return '';
+ }
+
+ return ''
+ . '| '
+ . 'Ghi chú: '
+ . htmlspecialchars((string) $this->invoice->note, ENT_QUOTES, 'UTF-8')
+ . ' |
';
}
}
diff --git a/app/Mail/VmInfoMail.php b/app/Mail/VmInfoMail.php
deleted file mode 100644
index b260e9a..0000000
--- a/app/Mail/VmInfoMail.php
+++ /dev/null
@@ -1,71 +0,0 @@
-data['os_name'] ?? '';
-
- return new Envelope(
- subject: "VPS của bạn đã sẵn sàng" . ($osName ? " — {$osName}" : ''),
- );
- }
-
- public function content(): Content
- {
- return new Content(
- view: 'emails.customer.send-info-vps',
- with: [
- 'companyName' => $this->data['company_name'] ?? '',
- 'companyLogo' => $this->data['company_logo'] ?? '',
- 'clientName' => $this->data['client_name'] ?? '',
- 'productName' => $this->data['product_name'] ?? '',
- 'productBilling' => $this->data['product_billing'] ?? '',
- 'productPrice' => $this->data['product_price'] ?? '',
- 'serviceHost' => $this->data['service_host'] ?? '',
- 'serviceUsername' => $this->data['service_username'] ?? '',
- 'servicePort' => $this->data['service_port'] ?? '',
- 'servicePassword' => $this->data['service_password'] ?? '',
- 'osName' => $this->data['os_name'] ?? '',
- 'serviceDueExp' => $this->data['service_due_exp'] ?? '',
- ],
- );
- }
-
- public function attachments(): array
- {
- if ($this->invoicePdf !== null && $this->invoicePdfFileName !== null) {
- return [
- Attachment::fromData(fn () => $this->invoicePdf, $this->invoicePdfFileName)
- ->withMime('application/pdf'),
- ];
- }
-
- return [];
- }
-}
diff --git a/app/Models/Customer.php b/app/Models/Customer.php
index 7bf024f..140d21e 100644
--- a/app/Models/Customer.php
+++ b/app/Models/Customer.php
@@ -24,6 +24,16 @@ class Customer extends Authenticatable implements JWTSubject
return ['uuid'];
}
+ /**
+ * Điểm tích luỹ cộng cho một khoản nạp: 1 credit / 100 VNĐ.
+ * Trước đây khớp sao kê tự động cộng credit bằng nguyên số tiền còn duyệt tay cộng
+ * amount/100, cùng một khoản ra hai kết quả lệch nhau 100 lần — nay đi chung một chỗ.
+ */
+ public static function creditFor(int $amount): int
+ {
+ return (int) floor(max(0, $amount) / 100);
+ }
+
protected $fillable = [
'fullname',
'username',
@@ -291,6 +301,15 @@ class Customer extends Authenticatable implements JWTSubject
public function getJWTCustomClaims()
{
- return [];
+ return ['pv' => $this->passwordVersion()];
+ }
+
+ /**
+ * Dấu vân tay của hash mật khẩu, nhúng vào JWT. Đổi/đặt lại mật khẩu → giá trị đổi →
+ * mọi access token cũ bị ApiAuthCheck từ chối (không cần blacklist).
+ */
+ public function passwordVersion(): string
+ {
+ return substr(hash('sha256', (string) $this->password), 0, 16);
}
}
diff --git a/app/Models/EmailNotificationTemplate.php b/app/Models/EmailNotificationTemplate.php
deleted file mode 100644
index 7beec25..0000000
--- a/app/Models/EmailNotificationTemplate.php
+++ /dev/null
@@ -1,97 +0,0 @@
- 'array',
- 'is_active' => 'boolean',
- ];
-
- /** Default blocks by template type */
- public static function defaultBlocks(string $type = 'invoice_notification'): array
- {
- if ($type === 'vm_credentials') {
- return [
- ['id' => 'greeting', 'type' => 'greeting', 'label' => 'Lời chào', 'enabled' => true, 'order' => 1, 'config' => []],
- ['id' => 'vm_credentials', 'type' => 'vm_credentials', 'label' => 'Thông tin truy cập VPS','enabled' => true, 'order' => 2, 'config' => [
- 'fields' => ['service_host', 'service_username', 'service_port', 'service_password', 'os_name'],
- ]],
- ['id' => 'product_info', 'type' => 'product_info', 'label' => 'Thông tin sản phẩm', 'enabled' => true, 'order' => 3, 'config' => [
- 'fields' => ['product_name', 'product_billing', 'product_price', 'service_due_exp'],
- ]],
- ['id' => 'custom_text', 'type' => 'custom_text', 'label' => 'Lời nhắn', 'enabled' => true, 'order' => 4, 'config' => [
- 'content' => 'Nếu bạn có bất kỳ thắc mắc nào, vui lòng liên hệ với chúng tôi để được hỗ trợ.',
- ]],
- ['id' => 'footer', 'type' => 'footer', 'label' => 'Footer', 'enabled' => true, 'order' => 5, 'config' => []],
- ];
- }
-
- // invoice_notification (default)
- return [
- ['id' => 'greeting', 'type' => 'greeting', 'label' => 'Lời chào', 'enabled' => true, 'order' => 1, 'config' => []],
- ['id' => 'invoice_info', 'type' => 'invoice_info', 'label' => 'Thông tin hóa đơn', 'enabled' => true, 'order' => 2, 'config' => [
- 'fields' => ['invoice_number', 'created_at', 'due_date', 'total', 'status'],
- ]],
- ['id' => 'customer_info','type' => 'customer_info','label' => 'Thông tin khách hàng', 'enabled' => false, 'order' => 3, 'config' => [
- 'fields' => ['name', 'email'],
- ]],
- ['id' => 'order_items', 'type' => 'order_items', 'label' => 'Chi tiết sản phẩm', 'enabled' => true, 'order' => 4, 'config' => []],
- ['id' => 'attachments', 'type' => 'attachments', 'label' => 'File đính kèm', 'enabled' => true, 'order' => 5, 'config' => []],
- ['id' => 'custom_text', 'type' => 'custom_text', 'label' => 'Lời nhắn', 'enabled' => true, 'order' => 6, 'config' => [
- 'content' => 'Nếu bạn có bất kỳ thắc mắc nào, vui lòng liên hệ với chúng tôi để được hỗ trợ.',
- ]],
- ['id' => 'footer', 'type' => 'footer', 'label' => 'Footer', 'enabled' => true, 'order' => 7, 'config' => []],
- ];
- }
-
- /** Field definitions exposed per block type */
- public static function blockFieldDefinitions(): array
- {
- return [
- 'invoice_info' => [
- ['key' => 'invoice_number', 'label' => 'Số hóa đơn'],
- ['key' => 'created_at', 'label' => 'Ngày tạo'],
- ['key' => 'due_date', 'label' => 'Hạn thanh toán'],
- ['key' => 'total', 'label' => 'Tổng tiền'],
- ['key' => 'sub_total', 'label' => 'Tiền dịch vụ'],
- ['key' => 'tax', 'label' => 'Thuế'],
- ['key' => 'status', 'label' => 'Trạng thái'],
- ['key' => 'payment_method', 'label' => 'Phương thức TT'],
- ],
- 'customer_info' => [
- ['key' => 'name', 'label' => 'Tên khách hàng'],
- ['key' => 'email', 'label' => 'Email'],
- ['key' => 'address', 'label' => 'Địa chỉ'],
- ],
- 'vm_credentials' => [
- ['key' => 'service_host', 'label' => 'Địa chỉ IP'],
- ['key' => 'service_username', 'label' => 'Username'],
- ['key' => 'service_port', 'label' => 'Port'],
- ['key' => 'service_password', 'label' => 'Mật khẩu'],
- ['key' => 'os_name', 'label' => 'Hệ điều hành'],
- ],
- 'product_info' => [
- ['key' => 'product_name', 'label' => 'Tên sản phẩm'],
- ['key' => 'product_billing', 'label' => 'Chu kỳ thanh toán'],
- ['key' => 'product_price', 'label' => 'Giá dịch vụ'],
- ['key' => 'service_due_exp', 'label' => 'Ngày hết hạn'],
- ],
- ];
- }
-}
diff --git a/app/Models/Order.php b/app/Models/Order.php
index 4dc6d50..7d3b2f3 100644
--- a/app/Models/Order.php
+++ b/app/Models/Order.php
@@ -58,15 +58,13 @@ class Order extends Model
// Helpers
// -------------------------------------------------------------------------
+ /**
+ * orders.status là tinyInteger (OrderStatus), không phải chuỗi — bản cũ match 'pending'/
+ * 'completed'… nên mọi giá trị đều rơi vào default và hiển thị ra "0", "1", "2".
+ */
public function getStatusLabelAttribute(): string
{
- return match ($this->status) {
- 'pending' => 'Chờ xử lý',
- 'processing' => 'Đang xử lý',
- 'completed' => 'Hoàn tất',
- 'cancelled' => 'Đã hủy',
- default => ucfirst($this->status),
- };
+ return \App\Enums\Order\OrderStatus::tryFrom((int) $this->status)?->label() ?? 'Không xác định';
}
public function payment()
diff --git a/app/Models/Template.php b/app/Models/Template.php
index ac97d1f..9152189 100644
--- a/app/Models/Template.php
+++ b/app/Models/Template.php
@@ -2,6 +2,7 @@
namespace App\Models;
+use App\Support\EmailTemplateShell;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Support\Facades\Cache;
@@ -81,7 +82,6 @@ class Template extends Model
'vm_creation_failed' => 'Khởi tạo VPS thất bại',
'vm_password_changed' => 'Đổi mật khẩu VPS',
'vm_rebuild_failed' => 'Rebuild VPS thất bại',
- 'holiday_notice' => 'Thông báo nghỉ lễ',
'domain_order_approved' => 'Tên miền được duyệt',
'domain_order_rejected' => 'Tên miền bị từ chối',
'order_created' => 'Xác nhận đơn hàng',
@@ -98,8 +98,11 @@ class Template extends Model
'customer_signup_verify' => 'Xác thực đăng ký tài khoản',
'hosting_information' => 'Thông tin truy cập Hosting',
'customer_password_reset' => 'Khôi phục mật khẩu khách hàng',
+ 'customer_password_reset_link' => 'Link đặt lại mật khẩu khách hàng',
'service_expiry' => 'Dịch vụ sắp hết hạn',
'customer_payment' => 'Thông báo thanh toán khách hàng',
+ 'invoice_payment_reminder' => 'Nhắc thanh toán hoá đơn',
+ 'invoice_notification' => 'Thông báo hoá đơn / gia hạn cước',
];
public function invoiceTemplate()
@@ -109,26 +112,20 @@ class Template extends Model
/**
* Wrap $contentHtml with the admin-editable "layout" template (scenario=layout),
- * replacing its {{$content}} placeholder. Falls back to the static blade
- * template-camp wrapper when no active layout template is configured.
+ * replacing its {{$content}} placeholder.
+ *
+ * Không còn fallback về blade: mọi email đều render từ tbl.template. Thiếu layout thì
+ * dùng khung header/footer chuẩn trong code thay vì gửi ra nội dung trần.
*/
public static function renderWithLayout(?string $subject, string $contentHtml): string
{
$layout = self::where('scenario', 'layout')->where('status', 1)->first();
- if (!$layout || !$layout->message) {
- return view('emails.customer.template-camp', [
- 'content' => $contentHtml,
- 'emailSubject' => $subject,
- ])->render();
- }
+ $body = $layout && $layout->message
+ ? str_replace('{{$content}}', $contentHtml, $layout->message)
+ : EmailTemplateShell::wrap(EmailTemplateShell::body($contentHtml));
- $body = str_replace('{{$content}}', $contentHtml, $layout->message);
-
- return view('emails.customer.template-envelope', [
- 'body' => $body,
- 'emailSubject' => $subject,
- ])->render();
+ return EmailTemplateShell::document($subject, $body);
}
public function type()
diff --git a/app/Models/Transaction.php b/app/Models/Transaction.php
index 939cd01..4f8b6bc 100644
--- a/app/Models/Transaction.php
+++ b/app/Models/Transaction.php
@@ -19,6 +19,7 @@ class Transaction extends Model
'public_code',
'trans',
'amount',
+ 'received_amount',
'customer_id',
'user_id',
'note',
diff --git a/app/Models/User.php b/app/Models/User.php
index 18cdd7b..91581ac 100644
--- a/app/Models/User.php
+++ b/app/Models/User.php
@@ -64,7 +64,7 @@ class User extends Authenticatable implements JWTSubject
public function getNameAttribute(): string
{
- return trim($this->firstname . ' ' . $this->lastname);
+ return trim((string) $this->fullname);
}
public function staffOnline()
diff --git a/app/Providers/RouteServiceProvider.php b/app/Providers/RouteServiceProvider.php
index aec3db7..047cf71 100644
--- a/app/Providers/RouteServiceProvider.php
+++ b/app/Providers/RouteServiceProvider.php
@@ -62,8 +62,19 @@ class RouteServiceProvider extends ServiceProvider
{
RateLimiter::for('api', function (Request $request) {
// Admin SPA makes many parallel requests per page — use a higher limit for authenticated users
- $limit = $request->user() ? 300 : 60;
- return Limit::perMinute($limit)->by($request->user()?->id ?: $request->ip());
+ // Guard mặc định là admin → khách (JWT guard client) phải lấy riêng, nếu không mọi khách bị đếm theo IP.
+ $key = ($admin = $request->user()) ? 'admin:' . $admin->id
+ : (($client = $request->user('client')) ? 'client:' . $client->id : null);
+
+ return Limit::perMinute($key ? 300 : 60)->by($key ?: $request->ip());
+ });
+
+ // Auth endpoint của khách (login/verify/forgot...). portal-client gọi API từ Next server nên
+ // IP gần như luôn là IP của Next → khoá theo route + email, không theo IP (tránh chặn chung mọi khách).
+ RateLimiter::for('client-auth', function (Request $request) {
+ $email = strtolower(trim((string) $request->input('email')));
+
+ return Limit::perMinute(5)->by($request->route()?->getName() . '|' . ($email ?: $request->ip()));
});
}
}
diff --git a/app/Repositories/Api/Invoice/InvoiceRepository.php b/app/Repositories/Api/Invoice/InvoiceRepository.php
index ba978ee..c016528 100644
--- a/app/Repositories/Api/Invoice/InvoiceRepository.php
+++ b/app/Repositories/Api/Invoice/InvoiceRepository.php
@@ -33,21 +33,27 @@ class InvoiceRepository extends BaseApiRepository implements InvoiceRepositoryIn
// createFromOrder
// -------------------------------------------------------------------------
- public function createFromOrder(Order $order): Invoice
+ public function createFromOrder(Order $order, ?int $dueDays = null): Invoice
{
- $maxMonths = $order->orderItems->max(fn($item) => OrderHelper::cycleToMonths($item->billing_cycle)) ?? 1;
+ $discount = (int) ($order->discount_amount ?? 0);
+
+ // due_date là HẠN THANH TOÁN, không phải ngày hết hạn dịch vụ — mốc dịch vụ đã có
+ // billing_period_end và services.expires_at lo. Cho phép truyền dueDays để admin gia hạn
+ // hạn thanh toán riêng cho một số khách.
+ $dueDays = $dueDays ?? (int) config('billing.invoice_due_days', 30);
$invoice = $this->model->create([
'invoice_number' => OrderHelper::generateOrderNumber('INV'),
'customer_id' => $order->customer_id,
'order_id' => $order->id,
'invoice_type' => 1, // new service
- 'sub_total' => $order->total,
- 'discount_amount' => $order->discount_amount ?? 0,
+ // sub_total là tổng TRƯỚC giảm giá, để sub_total - discount_amount = total.
+ 'sub_total' => (int) $order->total + $discount,
+ 'discount_amount' => $discount,
'tax' => 0,
'total' => $order->total,
'status' => InvoiceStatus::UNPAID->value,
- 'due_date' => now()->addMonths($maxMonths)->toDateString(),
+ 'due_date' => now()->addDays($dueDays)->toDateString(),
]);
// Create one invoice_item per order_item line + addon sub-lines
diff --git a/app/Repositories/Api/Invoice/InvoiceRepositoryInterface.php b/app/Repositories/Api/Invoice/InvoiceRepositoryInterface.php
index 5df5a32..a786114 100644
--- a/app/Repositories/Api/Invoice/InvoiceRepositoryInterface.php
+++ b/app/Repositories/Api/Invoice/InvoiceRepositoryInterface.php
@@ -17,5 +17,5 @@ interface InvoiceRepositoryInterface extends BaseRepositoryInterface
/**
* Create an invoice (+ invoice_items) from a completed Order.
*/
- public function createFromOrder(Order $order): Invoice;
+ public function createFromOrder(Order $order, ?int $dueDays = null): Invoice;
}
diff --git a/app/Repositories/Api/Order/OrderRepository.php b/app/Repositories/Api/Order/OrderRepository.php
index 8cb5632..b6263f1 100644
--- a/app/Repositories/Api/Order/OrderRepository.php
+++ b/app/Repositories/Api/Order/OrderRepository.php
@@ -56,9 +56,20 @@ class OrderRepository extends BaseApiRepository implements OrderRepositoryInterf
// hasPendingOrder
// -------------------------------------------------------------------------
+ /**
+ * Chặn khách tạo đơn mới khi còn đơn chưa thanh toán.
+ *
+ * Bỏ qua đơn import từ WHMCS (legacy_id khác null): dữ liệu cũ có rất nhiều đơn ở trạng
+ * thái pending, tính cả chúng thì sau khi đồng bộ gần như mọi khách hàng cũ đều bị khoá
+ * không đặt được đơn mới.
+ */
public function hasPendingOrder(int $customerId): bool
{
- return $this->model->where('customer_id', $customerId)->where('status', OrderStatus::PENDING->value)->exists();
+ return $this->model
+ ->where('customer_id', $customerId)
+ ->where('status', OrderStatus::PENDING->value)
+ ->whereNull('legacy_id')
+ ->exists();
}
// -------------------------------------------------------------------------
diff --git a/app/Services/Api/Client/ClientAuthService.php b/app/Services/Api/Client/ClientAuthService.php
index fd92fde..5dc04e3 100644
--- a/app/Services/Api/Client/ClientAuthService.php
+++ b/app/Services/Api/Client/ClientAuthService.php
@@ -4,7 +4,7 @@ namespace App\Services\Api\Client;
use App\Http\Services\Telegram;
use App\Mail\Admin\TrialRegistrationRequest as TrialRegistrationRequestMail;
-use App\Mail\Customer\PasswordResetByAdmin;
+use App\Mail\Customer\PasswordResetLink;
use App\Mail\Customer\VerifyAccount as VerifyAccountMail;
use App\Mail\Customer\Welcome as WelcomeMail;
use App\Models\Customer;
@@ -12,10 +12,11 @@ use App\Models\RefreshToken;
use App\Models\Setting;
use App\Repositories\Api\Client\ClientRepositoryInterface;
use Illuminate\Support\Facades\Auth;
-use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Hash;
use Illuminate\Support\Facades\Log;
use Illuminate\Support\Facades\Mail;
+use Illuminate\Support\Facades\Password;
+use Illuminate\Support\Facades\RateLimiter;
use Illuminate\Support\Str;
use Illuminate\Validation\ValidationException;
use Tymon\JWTAuth\Facades\JWTAuth;
@@ -25,6 +26,8 @@ class ClientAuthService
// Refresh token lifetime in days
const REFRESH_TOKEN_TTL_DAYS = 30;
+ const VERIFY_MAX_ATTEMPTS = 5;
+
protected $clientRepository;
public function __construct(ClientRepositoryInterface $clientRepository)
@@ -143,12 +146,28 @@ class ClientAuthService
{
$client = \App\Models\Customer::whereRaw('LOWER(email) = ?', [strtolower(trim($email))])->first();
- if (! $client || $client->email_verify_code !== $code) {
+ // Chống brute-force mã 6 số: sai quá VERIFY_MAX_ATTEMPTS lần thì huỷ mã, buộc gửi lại.
+ $attemptKey = 'verify-email:' . strtolower(trim($email));
+
+ if (! $client || ! $client->email_verify_code || ! hash_equals($client->email_verify_code, $code)) {
+ if ($client?->email_verify_code && RateLimiter::hit($attemptKey, 600) >= self::VERIFY_MAX_ATTEMPTS) {
+ RateLimiter::clear($attemptKey);
+ $client->email_verify_code = null;
+ $client->email_verify_code_expired_at = null;
+ $client->save();
+
+ throw ValidationException::withMessages([
+ 'code' => ['Nhập sai quá nhiều lần, vui lòng yêu cầu gửi lại mã xác thực.'],
+ ]);
+ }
+
throw ValidationException::withMessages([
'code' => ['Mã xác thực không chính xác.'],
]);
}
+ RateLimiter::clear($attemptKey);
+
if (! $client->email_verify_code_expired_at || now()->gt($client->email_verify_code_expired_at)) {
throw ValidationException::withMessages([
'code' => ['Mã xác thực đã hết hạn, vui lòng yêu cầu gửi lại.'],
@@ -305,10 +324,11 @@ class ClientAuthService
}
/**
- * Cấp mật khẩu mới ngẫu nhiên khi khách hàng quên mật khẩu. Không tiết lộ
- * email có tồn tại hay không — trả về null nếu không tìm thấy tài khoản.
+ * Gửi link đặt lại mật khẩu (token một lần, hết hạn theo auth.passwords.users.expire).
+ * Không đổi mật khẩu ngay — chỉ chủ hộp thư mới đổi được. Trả về null nếu không có
+ * tài khoản; caller không được tiết lộ kết quả cho client.
*/
- public function resetForgottenPassword(string $email): ?Customer
+ public function sendPasswordResetLink(string $email): ?Customer
{
$customer = Customer::whereRaw('LOWER(email) = ?', [strtolower(trim($email))])->first();
@@ -316,22 +336,49 @@ class ClientAuthService
return null;
}
- $newPassword = Str::password(16);
+ // Broker tự throttle theo email (auth.passwords.users.throttle).
+ Password::broker('users')->sendResetLink(
+ ['email' => $customer->email],
+ function (Customer $customer, string $token) {
+ $url = rtrim(config('app.frontend_url'), '/') . '/dat-lai-mat-khau?' . http_build_query([
+ 'token' => $token,
+ 'email' => $customer->email,
+ ]);
- DB::transaction(function () use ($customer, $newPassword) {
- $customer->password = Hash::make($newPassword);
- $customer->save();
-
- Mail::to($customer->email)->later(now()->addSeconds(5), new PasswordResetByAdmin([
- 'fullname' => $customer->fullname,
- 'email' => $customer->email,
- 'password' => $newPassword,
- ]));
- });
+ Mail::to($customer->email)->queue(new PasswordResetLink([
+ 'fullname' => $customer->fullname,
+ 'reset_url' => $url,
+ ]));
+ },
+ );
return $customer;
}
+ /**
+ * Đặt lại mật khẩu bằng token trong email. Thành công thì thu hồi toàn bộ refresh token
+ * để các phiên cũ (có thể của kẻ gian) phải đăng nhập lại.
+ */
+ public function resetPassword(array $data): ?Customer
+ {
+ $resetCustomer = null;
+
+ $status = Password::broker('users')->reset($data, function (Customer $customer, string $password) use (&$resetCustomer) {
+ $customer->password = Hash::make($password);
+ $customer->save();
+ RefreshToken::where('customer_id', $customer->id)->delete();
+ $resetCustomer = $customer;
+ });
+
+ if ($status !== Password::PASSWORD_RESET) {
+ throw ValidationException::withMessages([
+ 'token' => ['Link đặt lại mật khẩu không hợp lệ hoặc đã hết hạn.'],
+ ]);
+ }
+
+ return $resetCustomer;
+ }
+
/**
* Cập nhật thông tin cá nhân. Trả về old/dirty values để caller ghi activity log.
*/
@@ -375,5 +422,8 @@ class ClientAuthService
$client->password = Hash::make($newPassword);
$client->save();
+
+ // Access token cũ tự vô hiệu nhờ claim `pv`; thu hồi luôn refresh token để mọi phiên phải đăng nhập lại.
+ RefreshToken::where('customer_id', $client->id)->delete();
}
}
diff --git a/app/Services/Api/Order/OrderService.php b/app/Services/Api/Order/OrderService.php
index 58dc803..72f3219 100644
--- a/app/Services/Api/Order/OrderService.php
+++ b/app/Services/Api/Order/OrderService.php
@@ -5,14 +5,8 @@ namespace App\Services\Api\Order;
use App\Enums\Order\OrderStatus;
use App\Helpers\OrderHelper;
use App\Jobs\Mail\Admin\AdminOrderNotificationJob;
-use App\Jobs\VirtualMachine\Create\CreateVirtualMachineJob;
-use App\Models\VmProvisionLock;
use App\Models\Addon;
-use App\Models\Log as CustomerActivityLog;
use App\Models\Order;
-use App\Models\OrderItem;
-use App\Models\Service;
-use App\Models\ServiceAddon;
use App\Repositories\Api\Invoice\InvoiceRepository;
use App\Repositories\Api\Order\OrderRepository;
use App\Repositories\Api\Service\ServiceRepository;
@@ -144,110 +138,6 @@ class OrderService
});
}
- // =========================================================================
- // Private — provisionService
- // =========================================================================
-
- private function provisionService(Order $order, OrderItem $item, $customer): void
- {
- $invoice = $order->invoice;
- $months = $item->months ?: OrderHelper::cycleToMonths($item->billing_cycle);
- $expiresAt = now()->addMonths($months);
-
- $service = Service::create([
- 'customer_id' => $customer->id,
- 'product_id' => $item->product_id,
- 'type' => $item->product?->product_type,
- 'order_item_id' => $item->id,
- 'invoice_id' => $invoice?->id,
- 'server_name' => $item->server_name,
- 'status' => 'pending',
- 'auto_renew' => false,
- 'started_at' => now(),
- 'expires_at' => $expiresAt,
- ]);
-
- $this->attachServiceAddons($service, $item);
-
- // VPS: requires product + os_id → dispatch VM creation job
- if ($item->product_id && $item->os_id) {
- $this->dispatchVmJob($order, $item, $service, $customer);
- } else {
- // Hosting / Domain / non-VM services activate immediately
- $service->update(['status' => 'active']);
- }
- }
-
- // -------------------------------------------------------------------------
-
- /**
- * Create ServiceAddon rows from:
- * 1. Base product specs (cpu, ram, disk, bandwidth)
- * 2. Customer's configurable addon selections stored in addons_meta
- */
- private function attachServiceAddons(Service $service, OrderItem $item): void
- {
- // 1. Base product specs
- $product = $item->product;
- if ($product) {
- foreach (['cpu' => $product->cpu, 'ram' => $product->ram, 'disk' => $product->disk, 'bandwidth' => $product->bandwidth] as $type => $value) {
- if ($value !== null && $value !== '') {
- ServiceAddon::create([
- 'service_id' => $service->id,
- 'addon_type' => $type,
- 'addon_value' => (string) $value,
- 'price' => 0,
- ]);
- }
- }
- }
-
- // 2. Customer-selected addons
- $addonsMeta = is_array($item->addons_meta)
- ? $item->addons_meta
- : json_decode($item->addons_meta ?? '[]', true);
-
- foreach ((array) $addonsMeta as $meta) {
- $addon = Addon::find($meta['addon_id'] ?? null);
- if (!$addon) {
- continue;
- }
- ServiceAddon::create([
- 'service_id' => $service->id,
- 'addon_type' => strtolower((string) $addon->code),
- 'addon_value' => (string) ($meta['qty'] ?? 1),
- 'price' => (int) ($meta['price'] ?? $addon->price),
- ]);
- }
- }
-
- // -------------------------------------------------------------------------
-
- private function dispatchVmJob(Order $order, OrderItem $item, Service $service, $customer): void
- {
- try {
- if (VmProvisionLock::isRunning($customer->id)) {
- Log::warning('dispatchVmJob: blocked — customer already has a running VM provision', [
- 'customer_id' => $customer->id,
- 'order_item_id' => $item->id,
- ]);
- return;
- }
-
- dispatch(new CreateVirtualMachineJob($item));
-
- Log::info('dispatchVmJob: CreateVirtualMachineJob dispatched', [
- 'order_id' => $order->id,
- 'service_id' => $service->id,
- 'order_item_id' => $item->id,
- ]);
- } catch (Exception $e) {
- Log::error('dispatchVmJob: exception', [
- 'order_id' => $order->id,
- 'error' => $e->getMessage(),
- ]);
- }
- }
// =========================================================================
// Helpers
diff --git a/app/Services/EmailNotificationTemplateRenderer.php b/app/Services/EmailNotificationTemplateRenderer.php
deleted file mode 100644
index 380d8da..0000000
--- a/app/Services/EmailNotificationTemplateRenderer.php
+++ /dev/null
@@ -1,160 +0,0 @@
- 'https://res.cloudinary.com/izpast9i/image/upload/v1786503590/email_banner_cloud_vps.png',
- ];
-
- public function render(EmailNotificationTemplate $tpl, Invoice $invoice, string $companyName = ''): string
- {
- $blocks = collect($tpl->blocks)
- ->filter(fn($b) => $b['enabled'] ?? false)
- ->sortBy('order')
- ->values();
-
- $bodyHtml = '';
- foreach ($blocks as $block) {
- $bodyHtml .= $this->renderBlock($block, $invoice, $companyName);
- }
-
- $title = $tpl->header_title ?? 'Thông báo hóa đơn';
-
- return $this->wrapLayout($title, $bodyHtml, $companyName, self::BANNERS[$tpl->type] ?? null);
- }
-
- /**
- * Render a vm_credentials template using the emailData array from SendVmCredentialsEmailJob.
- * $emailData keys: company_name, client_name, product_name, product_billing, product_price,
- * service_host, service_username, service_port, service_password, os_name, service_due_exp
- */
- public function renderVmCredentials(EmailNotificationTemplate $tpl, array $emailData, ?Invoice $invoice = null): string
- {
- $blocks = collect($tpl->blocks)
- ->filter(fn($b) => $b['enabled'] ?? false)
- ->sortBy('order')
- ->values();
-
- $companyName = $emailData['company_name'] ?? '';
- $bodyHtml = '';
-
- foreach ($blocks as $block) {
- $bodyHtml .= match ($block['type'] ?? '') {
- 'greeting' => $this->renderView('emails.partials.greeting', ['name' => $emailData['client_name'] ?? 'Quý khách']),
- 'vm_credentials' => $this->renderView('emails.partials.vm-credentials', ['fields' => $block['config']['fields'] ?? null, 'data' => $emailData]),
- 'product_info' => $this->renderView('emails.partials.product-info', ['fields' => $block['config']['fields'] ?? null, 'data' => $emailData]),
- 'invoice_info' => $invoice ? $this->renderView('emails.partials.invoice-info', ['fields' => $block['config']['fields'] ?? null, 'invoice' => $invoice]) : '',
- 'order_items' => $invoice ? $this->renderOrderItems($invoice) : '',
- 'attachments' => $invoice ? $this->renderAttachments($invoice) : '',
- 'custom_text' => $this->renderView('emails.partials.custom-text', ['content' => $block['config']['content'] ?? '']),
- 'footer' => $this->renderView('emails.partials.signature', ['companyName' => $companyName]),
- default => '',
- };
- }
-
- $title = $tpl->header_title ?? 'Thông tin truy cập VPS';
-
- return $this->wrapLayout($title, $bodyHtml, $companyName, self::BANNERS[$tpl->type] ?? null);
- }
-
- public function renderPreview(EmailNotificationTemplate $tpl): string
- {
- if ($tpl->type === 'vm_credentials') {
- return $this->renderVmCredentials($tpl, $this->sampleVmData(), null);
- }
-
- $invoice = $this->sampleInvoice();
- return $this->render($tpl, $invoice, 'Công ty Demo');
- }
-
- // ─── Block renderers ──────────────────────────────────────────────────────
-
- private function renderBlock(array $block, Invoice $invoice, string $companyName): string
- {
- return match ($block['type'] ?? '') {
- 'greeting' => $this->renderView('emails.partials.greeting', ['name' => $invoice->customer?->fullname ?? 'Quý khách']),
- 'invoice_info' => $this->renderView('emails.partials.invoice-info', ['fields' => $block['config']['fields'] ?? null, 'invoice' => $invoice]),
- 'customer_info' => $this->renderView('emails.partials.customer-info', ['fields' => $block['config']['fields'] ?? null, 'customer' => $invoice->customer]),
- 'order_items' => $this->renderOrderItems($invoice),
- 'attachments' => $this->renderAttachments($invoice),
- 'custom_text' => $this->renderView('emails.partials.custom-text', ['content' => $block['config']['content'] ?? '']),
- 'footer' => $this->renderView('emails.partials.signature', ['companyName' => $companyName]),
- default => '',
- };
- }
-
- private function renderOrderItems(Invoice $invoice): string
- {
- $order = $invoice->order;
- if (!$order) return '';
-
- $order->loadMissing('orderItems.product');
-
- return $this->renderView('emails.partials.order-items', ['items' => $order->orderItems]);
- }
-
- private function renderAttachments(Invoice $invoice): string
- {
- $invoice->loadMissing('attachments');
-
- return $this->renderView('emails.partials.attachments', ['attachments' => $invoice->attachments]);
- }
-
- private function renderView(string $view, array $data): string
- {
- return View::make($view, $data)->render();
- }
-
- // ─── Layout wrapper ───────────────────────────────────────────────────────
-
- private function wrapLayout(string $title, string $body, string $companyName, ?string $bannerUrl = null): string
- {
- return View::make('emails.layouts.notification', [
- 'title' => $title,
- 'body' => $body,
- 'companyName' => $companyName ?: config('app.name', 'Thế Giới Số'),
- 'bannerUrl' => $bannerUrl,
- ])->render();
- }
-
- // ─── Sample data for preview ──────────────────────────────────────────────
-
- private function sampleVmData(): array
- {
- return [
- 'company_name' => 'Công ty Demo',
- 'company_logo' => '',
- 'client_name' => 'Nguyễn Văn A',
- 'product_name' => 'VPS Starter',
- 'product_billing' => 'Hàng tháng',
- 'product_price' => '420.000',
- 'service_host' => '203.113.x.x',
- 'service_username' => 'root',
- 'service_port' => '22',
- 'service_password' => 'P@ssw0rd!2026',
- 'os_name' => 'Ubuntu 22.04 LTS',
- 'service_due_exp' => date('d/m/Y', strtotime('+1 month')),
- ];
- }
-
- private function sampleInvoice(): Invoice
- {
- $invoice = new Invoice([
- 'invoice_number' => 'INV-20260521-00001',
- 'sub_total' => 420000,
- 'tax' => 0,
- 'total' => 420000,
- 'status' => 2,
- 'payment_method' => 'credit',
- ]);
- $invoice->created_at = now();
- $invoice->due_date = now()->addDays(7)->toDateString();
- return $invoice;
- }
-}
diff --git a/app/Services/InvoicePaymentService.php b/app/Services/InvoicePaymentService.php
index a65b434..bf80b7a 100644
--- a/app/Services/InvoicePaymentService.php
+++ b/app/Services/InvoicePaymentService.php
@@ -23,7 +23,30 @@ use Illuminate\Support\Str;
class InvoicePaymentService
{
- public function payWithBalance(Invoice $invoice, Customer $customer, string $method = 'balance'): Invoice
+ /** Trạng thái hoá đơn còn có thể thu tiền. Dùng chung cho cả khách trả lẫn admin xác nhận. */
+ public const PAYABLE_STATUSES = [InvoiceStatus::UNPAID->value, InvoiceStatus::OVERDUE->value];
+
+ /**
+ * Chặn thu tiền trên hoá đơn đã huỷ/đã hoàn — trước đây chỉ payWithBalance() kiểm tra,
+ * markPaidByAdmin() thì không, nên hoá đơn CANCELLED vẫn bật sang PAID và kích hoạt dịch vụ thật.
+ */
+ public static function assertPayable(Invoice $invoice): void
+ {
+ if (! in_array((int) $invoice->status, self::PAYABLE_STATUSES, true)) {
+ throw new \InvalidArgumentException(sprintf(
+ 'Hóa đơn #%s đang ở trạng thái "%s", không thể ghi nhận thanh toán.',
+ $invoice->invoice_number,
+ InvoiceStatus::tryFrom((int) $invoice->status)?->label() ?? 'không xác định',
+ ));
+ }
+ }
+
+ /**
+ * @param int|null $amount Số tiền muốn trả lần này. Null = trả hết phần còn thiếu.
+ * Trả ít hơn thì hoá đơn giữ nguyên UNPAID/OVERDUE, chỉ cộng dồn
+ * amount_paid — dịch vụ chưa được kích hoạt cho tới khi thu đủ.
+ */
+ public function payWithBalance(Invoice $invoice, Customer $customer, string $method = 'balance', ?int $amount = null): 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.');
@@ -35,7 +58,7 @@ class InvoicePaymentService
return $invoice->fresh(['order.orderItems.product', 'services', 'customer']);
}
- return DB::transaction(function () use ($invoice, $customer, $method) {
+ return DB::transaction(function () use ($invoice, $customer, $method, $amount) {
// Lock both rows so two concurrent payment requests for the same invoice/customer
// (double-click, retry) cannot both pass the balance check before either commits.
$invoice = Invoice::where('id', $invoice->id)->lockForUpdate()->firstOrFail();
@@ -45,40 +68,61 @@ class InvoicePaymentService
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.');
+ self::assertPayable($invoice);
+
+ $outstanding = self::outstanding($invoice);
+ $payNow = $amount === null ? $outstanding : min($amount, $outstanding);
+
+ if ($payNow <= 0) {
+ throw new \InvalidArgumentException('Số tiền thanh toán phải lớn hơn 0.');
}
- if ((int) $customer->total < (int) $invoice->total) {
+ if ((int) $customer->total < $payNow) {
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)
+ number_format($payNow)
));
}
- $customer->decrement('total', (int) $invoice->total);
+ $customer->decrement('total', $payNow);
+
+ $paidSoFar = (int) $invoice->amount_paid + $payNow;
+ $settled = $paidSoFar >= (int) $invoice->total;
$invoice->update([
- 'status' => InvoiceStatus::PAID->value,
- 'paid_at' => now(),
+ 'amount_paid' => $paidSoFar,
+ 'status' => $settled ? InvoiceStatus::PAID->value : $invoice->status,
+ 'paid_at' => $settled ? now() : null,
'payment_method' => $method,
]);
- $this->recordTransaction($invoice, $method, 'Khách hàng thanh toán hóa đơn #' . $invoice->invoice_number);
+ $this->recordTransaction(
+ $invoice,
+ $method,
+ 'Khách hàng thanh toán hóa đơn #' . $invoice->invoice_number
+ . ($settled ? '' : sprintf(' (một phần: %s/%s VNĐ)', number_format($paidSoFar), number_format((int) $invoice->total))),
+ amount: $payNow,
+ sequence: $settled && $payNow === (int) $invoice->total ? null : $paidSoFar,
+ );
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Đ.',
+ 'Thanh toán hóa đơn #%s. Số tiền: %s VNĐ.%s Số dư còn: %s VNĐ.',
$invoice->invoice_number,
- number_format((int) $invoice->total),
+ number_format($payNow),
+ $settled ? '' : sprintf(' Còn thiếu: %s VNĐ.', number_format(self::outstanding($invoice->fresh()))),
number_format((int) $customer->fresh()->total)
),
]);
+ if (! $settled) {
+ return $invoice->fresh(['order.orderItems.product', 'services', 'customer']);
+ }
+
if ((int) $invoice->invoice_type === 2) {
$this->activateRenewal($invoice);
} elseif ($invoice->order) {
@@ -89,20 +133,67 @@ class InvoicePaymentService
});
}
- public function markPaidByAdmin(Invoice $invoice, ?int $adminId = null, string $method = 'admin'): Invoice
+ /** Số tiền còn phải thu của một hoá đơn. */
+ public static function outstanding(Invoice $invoice): int
{
+ return max(0, (int) $invoice->total - (int) $invoice->amount_paid);
+ }
+
+ /**
+ * @param int|null $receivedAmount Số tiền admin xác nhận đã thực nhận. Null = thu đủ hoá đơn.
+ * Nhập ít hơn thì hoá đơn ghi nhận trả một phần và KHÔNG kích
+ * hoạt dịch vụ — tránh việc bấm "Đã thanh toán" khi tiền chưa
+ * về đủ mà sổ vẫn ghi đủ như trước.
+ */
+ public function markPaidByAdmin(
+ Invoice $invoice,
+ ?int $adminId = null,
+ string $method = 'admin',
+ ?int $receivedAmount = null,
+ ): Invoice {
if ((int) $invoice->status === InvoiceStatus::PAID->value) {
return $invoice->fresh(['order.orderItems.product', 'services', 'customer']);
}
- return DB::transaction(function () use ($invoice, $adminId, $method) {
+ return DB::transaction(function () use ($invoice, $adminId, $method, $receivedAmount) {
+ $invoice = Invoice::where('id', $invoice->id)->lockForUpdate()->firstOrFail();
+
+ if ((int) $invoice->status === InvoiceStatus::PAID->value) {
+ return $invoice->fresh(['order.orderItems.product', 'services', 'customer']);
+ }
+
+ self::assertPayable($invoice);
+
+ $outstanding = self::outstanding($invoice);
+ $received = $receivedAmount === null ? $outstanding : min(max(0, $receivedAmount), $outstanding);
+
+ if ($received <= 0) {
+ throw new \InvalidArgumentException('Số tiền thực nhận phải lớn hơn 0.');
+ }
+
+ $paidSoFar = (int) $invoice->amount_paid + $received;
+ $settled = $paidSoFar >= (int) $invoice->total;
+
$invoice->update([
- 'status' => InvoiceStatus::PAID->value,
- 'paid_at' => now(),
+ 'amount_paid' => $paidSoFar,
+ 'status' => $settled ? InvoiceStatus::PAID->value : $invoice->status,
+ 'paid_at' => $settled ? now() : null,
'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);
+ $this->recordTransaction(
+ $invoice,
+ $method,
+ 'Admin xác nhận thanh toán hóa đơn #' . $invoice->invoice_number
+ . ($settled ? '' : sprintf(' (một phần: %s/%s VNĐ)', number_format($paidSoFar), number_format((int) $invoice->total))),
+ $adminId,
+ amount: $received,
+ sequence: $settled && $received === (int) $invoice->total ? null : $paidSoFar,
+ );
+
+ if (! $settled) {
+ return $invoice->fresh(['order.orderItems.product', 'services', 'customer']);
+ }
if ((int) $invoice->invoice_type === 2) {
$this->activateRenewal($invoice);
@@ -114,18 +205,95 @@ class InvoicePaymentService
});
}
- public function recordTransaction(Invoice $invoice, string $method, string $note, ?int $adminId = null): Transaction
+ /**
+ * Hoàn tiền hoá đơn đã thu: cộng lại số dư, ghi Transaction âm, chuyển hoá đơn sang REFUNDED.
+ * Trước đây chỉ luồng tên miền có hoàn tiền, VPS/hosting phải làm tay và không để lại dấu vết.
+ */
+ public function refund(Invoice $invoice, ?int $adminId = null, ?int $amount = null, string $reason = ''): Invoice
{
+ if ((int) $invoice->status !== InvoiceStatus::PAID->value) {
+ throw new \InvalidArgumentException('Chỉ hoàn tiền được hoá đơn đã thanh toán.');
+ }
+
+ return DB::transaction(function () use ($invoice, $adminId, $amount, $reason) {
+ $invoice = Invoice::where('id', $invoice->id)->lockForUpdate()->firstOrFail();
+
+ if ((int) $invoice->status !== InvoiceStatus::PAID->value) {
+ throw new \InvalidArgumentException('Chỉ hoàn tiền được hoá đơn đã thanh toán.');
+ }
+
+ $paid = (int) $invoice->amount_paid ?: (int) $invoice->total;
+ $refundAmount = $amount === null ? $paid : min(max(0, $amount), $paid);
+
+ if ($refundAmount <= 0) {
+ throw new \InvalidArgumentException('Số tiền hoàn phải lớn hơn 0.');
+ }
+
+ $customer = Customer::where('id', $invoice->customer_id)->lockForUpdate()->firstOrFail();
+ $customer->increment('total', $refundAmount);
+
+ $invoice->update([
+ 'status' => InvoiceStatus::REFUNDED->value,
+ 'amount_paid' => max(0, $paid - $refundAmount),
+ ]);
+
+ Transaction::create([
+ 'uuid' => (string) Str::uuid(),
+ 'public_code' => 'INV-REF-' . now()->format('Ymd') . '-' . strtoupper(Str::random(6)),
+ 'trans' => $invoice->invoice_number . '-REFUND',
+ 'type' => 'refund',
+ 'amount' => -$refundAmount,
+ 'received_amount' => -$refundAmount,
+ 'customer_id' => $customer->id,
+ 'user_id' => $adminId,
+ 'status' => TransactionStatus::COMPLETED->value,
+ 'note' => trim('Hoàn tiền hóa đơn #' . $invoice->invoice_number . ' ' . $reason),
+ ]);
+
+ CustomerActivityLog::create([
+ 'agent' => 'admin',
+ 'client_ip' => request()?->ip(),
+ 'customer_id' => $customer->id,
+ 'description' => sprintf(
+ 'Hoàn tiền hóa đơn #%s: %s VNĐ. Số dư mới: %s VNĐ.%s',
+ $invoice->invoice_number,
+ number_format($refundAmount),
+ number_format((int) $customer->fresh()->total),
+ $reason ? ' Lý do: ' . $reason : '',
+ ),
+ ]);
+
+ return $invoice->fresh(['order.orderItems.product', 'services', 'customer']);
+ });
+ }
+
+ /**
+ * @param int|null $amount Số thực thu lần này; null = toàn bộ hoá đơn (hành vi cũ).
+ * @param int|null $sequence Khoá phụ để mỗi lần trả từng phần là một dòng riêng. Dưới row
+ * lock, amount_paid sau mỗi lần trả là duy nhất và tăng dần, nên
+ * vẫn chống được ghi trùng khi job retry.
+ */
+ public function recordTransaction(
+ Invoice $invoice,
+ string $method,
+ string $note,
+ ?int $adminId = null,
+ ?int $amount = null,
+ ?int $sequence = null,
+ ): Transaction {
+ $amount = $amount ?? (int) $invoice->total;
+
return Transaction::firstOrCreate(
[
'customer_id' => $invoice->customer_id,
- 'trans' => $invoice->invoice_number,
+ 'trans' => $invoice->invoice_number . ($sequence === null ? '' : '-P' . $sequence),
'type' => 'invoice',
],
[
'uuid' => (string) Str::uuid(),
'public_code' => 'INV-PAY-' . now()->format('Ymd') . '-' . strtoupper(Str::random(6)),
- 'amount' => (int) $invoice->total,
+ 'amount' => $amount,
+ 'received_amount' => $amount,
'user_id' => $adminId,
'status' => TransactionStatus::COMPLETED->value,
'note' => $note . ' (' . $method . ')',
diff --git a/app/Services/InvoiceReminderService.php b/app/Services/InvoiceReminderService.php
new file mode 100644
index 0000000..9a8e74a
--- /dev/null
+++ b/app/Services/InvoiceReminderService.php
@@ -0,0 +1,119 @@
+status, InvoicePaymentService::PAYABLE_STATUSES, true);
+ }
+
+ /**
+ * Số mốc nhắc đã đến hạn tính tới hôm nay, theo config('billing.reminder_offsets')
+ * (số âm = trước hạn, dương = sau hạn).
+ */
+ public function dueStep(Invoice $invoice, ?Carbon $now = null): int
+ {
+ if (! $invoice->due_date) {
+ return 0;
+ }
+
+ $now = $now ?? now();
+ $daysFromDue = $invoice->due_date->copy()->startOfDay()->diffInDays($now->copy()->startOfDay(), false);
+
+ $offsets = (array) config('billing.reminder_offsets', []);
+ if ($offsets === []) {
+ return 0;
+ }
+ sort($offsets);
+
+ // Quá mốc nhắc cuối cùng (cộng ân hạn) thì chuỗi nhắc đã đóng — không mở lại.
+ //
+ // Không có guard này, một lần import dữ liệu cũ (WHMCS) sẽ khiến mọi hoá đơn chưa
+ // thanh toán từ nhiều năm trước có reminder_step = 0 và due_date quá hạn rất lâu,
+ // cron 08:00 hôm sau bắn email nhắc nợ cho toàn bộ khách hàng cũ cùng lúc.
+ if ($daysFromDue > self::reminderCutoff($offsets)) {
+ return 0;
+ }
+
+ return count(array_filter($offsets, fn ($offset) => $daysFromDue >= $offset));
+ }
+
+ /** Ngày quá hạn tối đa còn nằm trong chuỗi nhắc. */
+ public static function reminderCutoff(array $offsets): int
+ {
+ return (int) max($offsets) + (int) config('billing.reminder_grace_days', 7);
+ }
+
+ /**
+ * Gửi nhắc nếu có mốc mới chưa gửi. Trả về true khi thực sự gửi.
+ * $force dùng cho nút "Nhắc thanh toán" thủ công của admin — bỏ qua việc đã gửi mốc đó rồi.
+ */
+ public function remind(Invoice $invoice, bool $force = false, ?Carbon $now = null): bool
+ {
+ if (! $this->isRemindable($invoice)) {
+ return false;
+ }
+
+ $dueStep = $this->dueStep($invoice, $now);
+
+ if (! $force && $dueStep <= (int) $invoice->reminder_step) {
+ return false;
+ }
+
+ $customer = $invoice->customer;
+ if (! $customer?->email) {
+ return false;
+ }
+
+ $this->templates->send(self::SCENARIO, $customer->email, $this->vars($invoice, $customer, $now));
+
+ $invoice->update(['reminder_step' => max($dueStep, (int) $invoice->reminder_step + ($force ? 1 : 0))]);
+
+ return true;
+ }
+
+ private function vars(Invoice $invoice, Customer $customer, ?Carbon $now): array
+ {
+ $now = $now ?? now();
+ $dueDate = $invoice->due_date;
+ $daysLate = $dueDate ? $dueDate->copy()->startOfDay()->diffInDays($now->copy()->startOfDay(), false) : 0;
+ $outstanding = max(0, (int) $invoice->total - (int) ($invoice->amount_paid ?? 0));
+
+ return [
+ 'client_name' => $customer->fullname ?? '',
+ 'invoice_number' => $invoice->invoice_number,
+ 'total' => number_format((int) $invoice->total, 0, ',', '.'),
+ 'outstanding' => number_format($outstanding, 0, ',', '.'),
+ 'due_date' => $dueDate?->format('d/m/Y') ?? '-',
+ 'status_line' => $daysLate > 0
+ ? 'Hoá đơn đã quá hạn ' . $daysLate . ' ngày.'
+ : ($daysLate === 0 ? 'Hôm nay là hạn thanh toán của hoá đơn này.' : 'Hoá đơn sẽ đến hạn sau ' . abs($daysLate) . ' ngày.'),
+ 'paid_row' => (int) ($invoice->amount_paid ?? 0) > 0
+ ? '| Đã thanh toán: | '
+ . number_format((int) $invoice->amount_paid, 0, ',', '.') . ' VNĐ |
'
+ : '',
+ 'login_url' => rtrim((string) config('app.frontend_url', ''), '/') . '/dang-nhap',
+ 'support_email' => 'support@tgs.com.vn',
+ 'support_phone' => '(028) 7309. 7379',
+ ];
+ }
+}
diff --git a/app/Services/VMDataService.php b/app/Services/VMDataService.php
deleted file mode 100644
index 21b1526..0000000
--- a/app/Services/VMDataService.php
+++ /dev/null
@@ -1,244 +0,0 @@
-server_name;
-
- // Chỉ lưu dữ liệu khi có server_name
- if (!$serverName) {
- Log::info('ℹ️ Bỏ qua lưu dữ liệu VM vì không có server_name trong order item', [
- 'order_item_id' => $orderItem->id,
- 'vmware_id' => $vmwareId
- ]);
-
- return [
- 'success' => true,
- 'message' => 'Bỏ qua lưu dữ liệu VM vì không có server_name trong order item',
- 'skipped' => true
- ];
- }
-
- try {
- DB::beginTransaction();
-
- $product = $orderItem->getProductAttribute();
- $customer = $orderItem->order->customer;
- $host = $product->host;
-
- if (!$product || !$customer || !$host) {
- throw new \Exception('Thiếu thông tin sản phẩm, khách hàng hoặc host');
- }
-
- // Tạo mới IP address với server_name
- $ipAddress = $this->createIpAddress($orderItem, $host, $ipAddresses, $serverName);
-
- // Tạo mới VM với server_name
- $vm = $this->createVM($orderItem, $vmwareId, $ipAddress, $customer, $serverName);
-
- DB::commit();
-
- Log::info('✅ Lưu dữ liệu VM thành công với server_name từ order item', [
- 'vm_id' => $vm->id,
- 'vmware_id' => $vm->vmware_id,
- 'ip_address_id' => $ipAddress->id,
- 'order_item_id' => $orderItem->id,
- 'customer_id' => $customer->id,
- 'server_name' => $serverName
- ]);
-
- return [
- 'success' => true,
- 'vm' => $vm,
- 'ip_address' => $ipAddress,
- 'message' => 'Lưu dữ liệu VM thành công với server_name từ order item'
- ];
-
- } catch (\Exception $e) {
- DB::rollBack();
-
- Log::error('❌ Lỗi khi lưu dữ liệu VM', [
- 'order_item_id' => $orderItem->id,
- 'vmware_id' => $vmwareId,
- 'server_name' => $serverName,
- 'error' => $e->getMessage(),
- 'trace' => $e->getTraceAsString()
- ]);
-
- return [
- 'success' => false,
- 'message' => 'Lỗi khi lưu dữ liệu VM: ' . $e->getMessage()
- ];
- }
- }
-
- /**
- * Tạo mới IP address với server_name
- */
- private function createIpAddress(OrderItem $orderItem, $host, array $ipAddresses, string $serverName): ModuleIpAddress
- {
- $product = $orderItem->getProductAttribute();
-
- // Lấy IP đầu tiên từ danh sách IP addresses
- $primaryIp = null;
- if (!empty($ipAddresses)) {
- $primaryIp = $ipAddresses[0]['ip'] ?? null;
- }
-
- // Nếu không có IP từ vCenter, sử dụng IP từ sản phẩm
- if (!$primaryIp && $product->ipAddress) {
- $primaryIp = $product->ipAddress->ip;
- }
-
- $ipData = [
- 'vm_name' => $serverName,
- 'module_host_id' => $host->id,
- 'module_network_id' => $product->ipAddress?->module_network_id ?? 1, // Default network
- 'ip' => $primaryIp ?? '0.0.0.0',
- 'gateway' => $product->ipAddress?->gateway ?? '0.0.0.0', // Default gateway
- 'netmask' => $product->ipAddress?->netmask ?? '255.255.255.0',
- 'dns' => $product->ipAddress?->dns ?? '8.8.8.8',
- 'status' => $primaryIp ? 1 : 0 // 1 = Assigned, 0 = Free
- ];
-
- $newIp = ModuleIpAddress::create($ipData);
- Log::info('✅ Tạo mới IP address với server_name', [
- 'ip_id' => $newIp->id,
- 'ip' => $ipData['ip'],
- 'vm_name' => $ipData['vm_name'],
- 'server_name' => $serverName
- ]);
- return $newIp;
- }
-
- /**
- * Tạo mới VM với server_name
- */
- private function createVM(OrderItem $orderItem, string $vmwareId, ModuleIpAddress $ipAddress, $customer, string $serverName): ModuleVmware
- {
- $product = $orderItem->getProductAttribute();
-
- // Xử lý payment fields với type casting
- $firstPayment = $orderItem->order->first_payment;
- $nextPayment = $orderItem->order->next_payment;
- $nextInvoice = $orderItem->order->next_invoice;
-
- $vmData = [
- 'name' => $serverName,
- 'module_ip_address_id' => $ipAddress->id,
- 'port' => 1, // Default port
- 'order_id' => $orderItem->order->id,
- 'first_payment' => is_numeric($firstPayment) ? (int) $firstPayment : 0, // Ensure integer
- 'next_payment' => is_numeric($nextPayment) ? (int) $nextPayment : 0, // Ensure integer
- 'next_due_date' => $orderItem->order->next_due_date ?? null,
- 'next_invoice' => is_numeric($nextInvoice) ? (int) $nextInvoice : 0, // Ensure integer
- 'user' => 'admin', // Default user
- 'password' => '', // Will be set later
- 'hostname' => $serverName, // Sử dụng server_name cho hostname
- 'vmware_id' => $vmwareId,
- 'os_id' => $orderItem->os_id ?? 0,
- 'customer_id' => $customer->id,
- 'status' => 1 // Active
- ];
-
- $newVM = ModuleVmware::create($vmData);
- Log::info('✅ Tạo mới VM với server_name', [
- 'vm_id' => $newVM->id,
- 'vmware_id' => $vmwareId,
- 'name' => $serverName,
- 'server_name' => $serverName
- ]);
- return $newVM;
- }
-
- /**
- * Cập nhật trạng thái VM
- */
- public function updateVMStatus(string $vmwareId, int $status): bool
- {
- try {
- $vm = ModuleVmware::where('vmware_id', $vmwareId)->first();
-
- if (!$vm) {
- Log::warning('⚠️ Không tìm thấy VM để cập nhật trạng thái', [
- 'vmware_id' => $vmwareId,
- 'status' => $status
- ]);
- return false;
- }
-
- $vm->update(['status' => $status]);
-
- Log::info('🔄 Cập nhật trạng thái VM', [
- 'vm_id' => $vm->id,
- 'vmware_id' => $vmwareId,
- 'old_status' => $vm->getOriginal('status'),
- 'new_status' => $status
- ]);
-
- return true;
-
- } catch (\Exception $e) {
- Log::error('❌ Lỗi khi cập nhật trạng thái VM', [
- 'vmware_id' => $vmwareId,
- 'status' => $status,
- 'error' => $e->getMessage()
- ]);
- return false;
- }
- }
-
- /**
- * Cập nhật IP address cho VM
- */
- public function updateVMIPAddress(string $vmwareId, string $ipAddress): bool
- {
- try {
- $vm = ModuleVmware::where('vmware_id', $vmwareId)->first();
-
- if (!$vm || !$vm->ipAddress) {
- Log::warning('⚠️ Không tìm thấy VM hoặc IP address để cập nhật', [
- 'vmware_id' => $vmwareId,
- 'ip' => $ipAddress
- ]);
- return false;
- }
-
- $vm->ipAddress->update([
- 'ip' => $ipAddress,
- 'status' => 1 // Assigned
- ]);
-
- Log::info('🔄 Cập nhật IP address cho VM', [
- 'vm_id' => $vm->id,
- 'vmware_id' => $vmwareId,
- 'ip' => $ipAddress
- ]);
-
- return true;
-
- } catch (\Exception $e) {
- Log::error('❌ Lỗi khi cập nhật IP address cho VM', [
- 'vmware_id' => $vmwareId,
- 'ip' => $ipAddress,
- 'error' => $e->getMessage()
- ]);
- return false;
- }
- }
-}
\ No newline at end of file
diff --git a/app/Support/EmailTemplateShell.php b/app/Support/EmailTemplateShell.php
new file mode 100644
index 0000000..f6cc20a
--- /dev/null
+++ b/app/Support/EmailTemplateShell.php
@@ -0,0 +1,92 @@
+.
+ *
+ * Thông tin công ty hardcode ở footer vì EmailTemplateRenderer chỉ thay placeholder dạng
+ * {{key}} đã khai báo trong metadata.variables — mọi scenario đều phải khai lại nếu muốn
+ * footer động, không đáng.
+ */
+class EmailTemplateShell
+{
+ /** Bọc phần nội dung vào khung chuẩn. */
+ public static function wrap(string $bodyHtml): string
+ {
+ return self::header() . $bodyHtml . self::footer();
+ }
+
+ /**
+ * Mở khối NoiDungKM với style chữ chuẩn, để scenario chỉ viết nội dung bên trong.
+ */
+ public static function body(string $innerHtml): string
+ {
+ return '| '
+ . $innerHtml
+ . ' |
';
+ }
+
+ /** Khối "Lưu ý: email tự động" dùng ở cuối hầu hết scenario. */
+ public static function autoNotice(): string
+ {
+ return <<<'HTML'
+
+| Lưu ý: Đây là email được gửi tự động. Vui lòng không trả lời email này. |
+
+HTML;
+ }
+
+ public static function header(): string
+ {
+ return <<<'HTML'
+
+
+HTML;
+ }
+
+ public static function footer(): string
+ {
+ return <<<'HTML'
+
+ |
+HTML;
+ }
+
+ /**
+ * Bọc một đoạn HTML thành email hoàn chỉnh (doctype + head). Thay cho blade
+ * emails.customer.template-envelope — để không còn mail nào đi qua Blade.
+ */
+ public static function document(?string $title, string $bodyHtml): string
+ {
+ $safeTitle = htmlspecialchars((string) $title, ENT_QUOTES, 'UTF-8');
+
+ return <<
+
+
+
+
+{$safeTitle}
+
+
+{$bodyHtml}
+
+
+HTML;
+ }
+}
diff --git a/config/auth.php b/config/auth.php
index 446747d..193b440 100644
--- a/config/auth.php
+++ b/config/auth.php
@@ -98,6 +98,7 @@ return [
'provider' => 'users',
'table' => 'password_resets',
'expire' => 60,
+ 'throttle' => 60,
],
'admins' => [
'provider' => 'admins',
diff --git a/config/billing.php b/config/billing.php
new file mode 100644
index 0000000..763a5b2
--- /dev/null
+++ b/config/billing.php
@@ -0,0 +1,42 @@
+ (int) env('BILLING_INVOICE_DUE_DAYS', 30),
+
+ /*
+ | Hạn thanh toán cho hoá đơn gia hạn khi dịch vụ đã hết hạn (không còn mốc expires_at
+ | trong tương lai để lấy làm hạn).
+ */
+ 'renewal_due_days' => (int) env('BILLING_RENEWAL_DUE_DAYS', 7),
+
+ /*
+ | Lịch nhắc nợ, tính theo số ngày so với due_date. Số âm = trước hạn, 0 = đúng hạn,
+ | số dương = sau hạn. Mỗi mốc gửi tối đa một lần, theo dõi qua invoices.reminder_step.
+ */
+ 'reminder_offsets' => [-7, -3, 0, 3, 7],
+
+ /*
+ | Biên độ ân hạn sau mốc nhắc cuối cùng. Quá mốc cuối + số ngày này thì chuỗi nhắc coi
+ | như đã đóng, không mở lại — nếu không, một lần import dữ liệu cũ (WHMCS) sẽ làm cron
+ | bắn email nhắc nợ cho toàn bộ hoá đơn quá hạn nhiều năm cùng lúc.
+ | Giữ ân hạn thay vì cắt đúng mốc cuối để cron lỡ vài ngày vẫn gửi bù được.
+ */
+ 'reminder_grace_days' => (int) env('BILLING_REMINDER_GRACE_DAYS', 7),
+
+ /*
+ | Giá gia hạn lấy theo bảng giá hiện hành tại thời điểm xuất hoá đơn thay vì đóng băng
+ | theo giá lúc mua. Đặt false để quay lại hành vi cũ (khoá giá theo hợp đồng).
+ */
+ 'renewal_uses_current_price' => (bool) env('BILLING_RENEWAL_CURRENT_PRICE', true),
+
+ /*
+ | Số ngày giữ lại tài nguyên (VM/hosting) sau khi dịch vụ chuyển sang terminated,
+ | trước khi job thu hồi xoá hẳn.
+ */
+ 'terminate_grace_days' => (int) env('BILLING_TERMINATE_GRACE_DAYS', 0),
+];
diff --git a/config/cors.php b/config/cors.php
index ce26015..c9516c3 100644
--- a/config/cors.php
+++ b/config/cors.php
@@ -19,14 +19,15 @@ return [
'allowed_methods' => ['*'],
'allowed_origins' => [
- 'http://localhost:3000',
- 'http://localhost:5173',
- 'http://localhost:5174',
- 'http://localhost:4173',
'https://portal.tgs.com.vn',
'https://admin.portal.tgs.com.vn',
- 'http://portal.tgs.com.vn',
- 'http://admin.portal.tgs.com.vn',
+ // Origin dev chỉ bật ở môi trường local (supports_credentials = true nên không để lọt lên prod).
+ ...(env('APP_ENV') === 'local' ? [
+ 'http://localhost:3000',
+ 'http://localhost:5173',
+ 'http://localhost:5174',
+ 'http://localhost:4173',
+ ] : []),
],
'allowed_origins_patterns' => [],
diff --git a/config/services.php b/config/services.php
index c0447b4..f3da16f 100644
--- a/config/services.php
+++ b/config/services.php
@@ -42,7 +42,7 @@ return [
'client_api' => [
// openssl rand -hex 32
'key' => env('CLIENT_API_ACCESS_KEY'),
- 'secret' => env('CLIENT_API_ACCESS_SECRET', 'tgs-client-api'),
+ 'secret' => env('CLIENT_API_ACCESS_SECRET'),
],
];
diff --git a/database/factories/UserFactory.php b/database/factories/UserFactory.php
index a448265..fe4bc14 100644
--- a/database/factories/UserFactory.php
+++ b/database/factories/UserFactory.php
@@ -18,8 +18,9 @@ class UserFactory extends Factory
public function definition()
{
return [
- 'firstname' => "Sysmtem",
- 'lastname' => "Admin",
+ // Bảng `user` dùng một cột `fullname`; firstname/lastname không tồn tại nên mọi
+ // test cần tài khoản admin đều chết ở đây với "Unknown column 'firstname'".
+ 'fullname' => 'System Admin',
'username' => "admin",
'email' => 'admin@tgs.com.vn',
'email_verified_at' => now(),
diff --git a/database/migrations/2026_09_18_000001_seed_vm_credentials_email_template.php b/database/migrations/2026_09_18_000001_seed_vm_credentials_email_template.php
new file mode 100644
index 0000000..371eddd
--- /dev/null
+++ b/database/migrations/2026_09_18_000001_seed_vm_credentials_email_template.php
@@ -0,0 +1,81 @@
+where('scenario', 'vm_credentials')->exists()) {
+ return;
+ }
+
+ $variables = [
+ ['key' => 'app_name', 'label' => 'Tên công ty', 'type' => 'string', 'example' => 'Thế Giới Số'],
+ ['key' => 'full_name', 'label' => 'Tên khách hàng', 'type' => 'string', 'example' => 'Quý khách'],
+ ['key' => 'product_name', 'label' => 'Tên dịch vụ', 'type' => 'string', 'example' => 'Cloud VPS Pro'],
+ ['key' => 'product_billing_row', 'label' => '[Tự động] Dòng chu kỳ thanh toán', 'type' => 'html', 'example' => ''],
+ ['key' => 'product_price_row', 'label' => '[Tự động] Dòng giá dịch vụ', 'type' => 'html', 'example' => ''],
+ ['key' => 'os_name_row', 'label' => '[Tự động] Dòng hệ điều hành', 'type' => 'html', 'example' => ''],
+ ['key' => 'service_host', 'label' => 'Địa chỉ IP', 'type' => 'string', 'example' => '1.2.3.4'],
+ ['key' => 'service_username', 'label' => 'Tài khoản đăng nhập', 'type' => 'string', 'example' => 'root'],
+ ['key' => 'service_password', 'label' => 'Mật khẩu', 'type' => 'string', 'example' => 'P@ssw0rd123'],
+ ['key' => 'service_port_row', 'label' => '[Tự động] Dòng cổng kết nối', 'type' => 'html', 'example' => ''],
+ ['key' => 'service_due_exp_row', 'label' => '[Tự động] Dòng hạn sử dụng', 'type' => 'html', 'example' => ''],
+ ['key' => 'login_url', 'label' => 'Link đăng nhập', 'type' => 'url', 'example' => 'https://portal.tgs.com.vn/dang-nhap'],
+ ['key' => 'support_email', 'label' => 'Email hỗ trợ', 'type' => 'string', 'example' => 'support@tgs.com.vn'],
+ ['key' => 'support_phone', 'label' => 'Hotline hỗ trợ', 'type' => 'string', 'example' => '(028) 7309.7379'],
+ ];
+
+ $message = <<<'HTML'
+
+
+|
+ 
+VPS của bạn đã sẵn sàng
+Kính chào {{full_name}},
+Dịch vụ {{product_name}} tại {{app_name}} đã được khởi tạo thành công. Dưới đây là thông tin truy cập:
+
+| Gói dịch vụ: | {{product_name}} |
+{{product_billing_row}}
+{{product_price_row}}
+{{os_name_row}}
+| Địa chỉ IP: | {{service_host}} |
+| Tài khoản đăng nhập: | {{service_username}} |
+| Mật khẩu: | {{service_password}} |
+{{service_port_row}}
+{{service_due_exp_row}}
+| Trạng thái: | Đang hoạt động ✓ |
+
+Lưu ý: Vì lý do bảo mật, mật khẩu chỉ được gửi một lần duy nhất qua email và không được lưu trữ trên hệ thống. Vui lòng lưu lại ở nơi an toàn.
+Bạn có thể đăng nhập vào cổng quản lý để theo dõi và quản lý dịch vụ của mình.
+
+Nếu bạn không yêu cầu dịch vụ này, vui lòng liên hệ ngay với chúng tôi qua {{support_email}} hoặc {{support_phone}}.
+Email này được gửi tự động. Vui lòng không trả lời email này.
+--- Hotline: {{support_phone}} Email: {{support_email}}
+ |
+
+ |
+HTML;
+
+ DB::table('template')->insert([
+ 'type_id' => 0,
+ 'scenario' => 'vm_credentials',
+ 'key' => 'vm_credentials',
+ 'format' => 'html',
+ 'name' => 'Khởi tạo dịch vụ (thông tin truy cập VPS)',
+ 'subject' => 'VPS của bạn đã sẵn sàng — {{product_name}}',
+ 'message' => $message,
+ 'metadata' => json_encode(['variables' => $variables], JSON_UNESCAPED_UNICODE),
+ 'status' => 1,
+ 'created_at' => now(),
+ 'updated_at' => now(),
+ ]);
+ }
+
+ public function down(): void
+ {
+ DB::table('template')->where('scenario', 'vm_credentials')->delete();
+ }
+};
diff --git a/database/migrations/2026_09_20_000001_seed_domain_status_and_renewal_templates.php b/database/migrations/2026_09_20_000001_seed_domain_status_and_renewal_templates.php
new file mode 100644
index 0000000..91b550b
--- /dev/null
+++ b/database/migrations/2026_09_20_000001_seed_domain_status_and_renewal_templates.php
@@ -0,0 +1,174 @@
+templates() as $scenario => $item) {
+ $row = [
+ 'scenario' => $scenario,
+ 'key' => $scenario,
+ 'format' => 'html',
+ 'type_id' => 0,
+ 'status' => 1,
+ 'name' => $item['name'],
+ 'subject' => $item['subject'],
+ 'message' => $this->header() . $item['body'] . $this->footer(),
+ 'metadata' => json_encode(['variables' => $item['variables']], JSON_UNESCAPED_UNICODE),
+ ];
+
+ $existing = DB::table('template')->where('scenario', $scenario)->first();
+
+ if ($existing) {
+ DB::table('template')->where('id', $existing->id)->update($row + ['updated_at' => now()]);
+ } else {
+ DB::table('template')->insert($row + ['created_at' => now(), 'updated_at' => now()]);
+ }
+ }
+
+ DB::table('template')->where('scenario', 'holiday_notice')->delete();
+ }
+
+ public function down(): void
+ {
+ DB::table('template')->whereIn('scenario', self::SCENARIOS)->delete();
+ }
+
+ private function templates(): array
+ {
+ return [
+ 'domain_order_approved' => [
+ 'name' => 'Tên miền được duyệt',
+ 'subject' => 'Tên miền {{domain_name}} đã được duyệt đăng ký',
+ 'variables' => [
+ ['key' => 'company_name', 'label' => 'Tên công ty', 'type' => 'string', 'example' => 'Thế Giới Số'],
+ ['key' => 'company_logo', 'label' => 'Logo công ty (URL)', 'type' => 'url', 'example' => ''],
+ ['key' => 'client_name', 'label' => 'Tên khách hàng', 'type' => 'string', 'example' => 'Nguyễn Văn A'],
+ ['key' => 'domain_name', 'label' => 'Tên miền', 'type' => 'string', 'example' => 'example.vn'],
+ ['key' => 'order_num', 'label' => 'Mã đơn hàng', 'type' => 'string', 'example' => 'DH001'],
+ ],
+ 'body' => <<<'HTML'
+|
+ Tên miền đã được duyệt đăng ký
+Kính chào {{client_name}},
+Đơn đăng ký tên miền của Quý khách tại {{company_name}} đã được duyệt và kích hoạt thành công. Tên miền hiện đã sẵn sàng để Quý khách trỏ về dịch vụ của mình.
+
+| Tên miền: | {{domain_name}} |
+| Mã đơn hàng: | {{order_num}} |
+| Trạng thái: | Đã kích hoạt ✓ |
+
+Quý khách có thể quản lý tên miền, cấu hình DNS và theo dõi hạn sử dụng ngay trong mục Đơn hàng trên cổng khách hàng.
+
+| Lưu ý: Đây là email được gửi tự động. Vui lòng không trả lời email này. |
+
+ |
+HTML,
+ ],
+
+ 'domain_order_rejected' => [
+ 'name' => 'Tên miền bị từ chối',
+ 'subject' => 'Tên miền {{domain_name}} đã bị từ chối đăng ký',
+ 'variables' => [
+ ['key' => 'company_name', 'label' => 'Tên công ty', 'type' => 'string', 'example' => 'Thế Giới Số'],
+ ['key' => 'company_logo', 'label' => 'Logo công ty (URL)', 'type' => 'url', 'example' => ''],
+ ['key' => 'client_name', 'label' => 'Tên khách hàng', 'type' => 'string', 'example' => 'Nguyễn Văn A'],
+ ['key' => 'domain_name', 'label' => 'Tên miền', 'type' => 'string', 'example' => 'example.vn'],
+ ['key' => 'order_num', 'label' => 'Mã đơn hàng', 'type' => 'string', 'example' => 'DH001'],
+ ['key' => 'reason', 'label' => 'Lý do từ chối', 'type' => 'string', 'example' => 'Tên miền đã có chủ sở hữu'],
+ ],
+ 'body' => <<<'HTML'
+|
+ Đơn đăng ký tên miền bị từ chối
+Kính chào {{client_name}},
+Rất tiếc, đơn đăng ký tên miền của Quý khách tại {{company_name}} không thể tiếp tục xử lý. Số tiền của đơn hàng đã được hoàn lại vào số dư tài khoản của Quý khách.
+
+| Tên miền: | {{domain_name}} |
+| Mã đơn hàng: | {{order_num}} |
+| Trạng thái: | Bị từ chối |
+
+
+Quý khách có thể đăng ký lại với tên miền khác, hoặc liên hệ bộ phận hỗ trợ để được tư vấn thêm.
+
+| Lưu ý: Đây là email được gửi tự động. Vui lòng không trả lời email này. |
+
+ |
+HTML,
+ ],
+
+ 'renewal_reminder' => [
+ 'name' => 'Nhắc gia hạn',
+ 'subject' => 'Nhắc gia hạn dịch vụ {{product_name}}',
+ 'variables' => [
+ ['key' => 'client_name', 'label' => 'Tên khách hàng', 'type' => 'string', 'example' => 'Nguyễn Văn A'],
+ ['key' => 'product_name', 'label' => 'Tên dịch vụ', 'type' => 'string', 'example' => 'Cloud VPS Pro'],
+ ['key' => 'server_name_suffix', 'label' => '[Tự động] Tên server trong ngoặc', 'type' => 'html', 'example' => ''],
+ ['key' => 'expires_at', 'label' => 'Ngày hết hạn', 'type' => 'string', 'example' => '31/12/2026'],
+ ['key' => 'price_row', 'label' => '[Tự động] Dòng số tiền gia hạn', 'type' => 'html', 'example' => ''],
+ ['key' => 'login_url', 'label' => 'Link đăng nhập', 'type' => 'url', 'example' => 'https://portal.tgs.com.vn/dang-nhap'],
+ ['key' => 'support_email', 'label' => 'Email hỗ trợ', 'type' => 'string', 'example' => 'support@tgs.com.vn'],
+ ['key' => 'support_phone', 'label' => 'Hotline hỗ trợ', 'type' => 'string', 'example' => '(028) 7309. 7379'],
+ ],
+ 'body' => <<<'HTML'
+|
+ Nhắc gia hạn dịch vụ
+Kính chào {{client_name}},
+Dịch vụ {{product_name}}{{server_name_suffix}} của Quý khách sắp đến hạn. Vui lòng gia hạn trước ngày hết hạn để dịch vụ không bị gián đoạn.
+
+| Dịch vụ: | {{product_name}} |
+| Ngày hết hạn: | {{expires_at}} |
+{{price_row}}
+
+
+Nếu Quý khách đã thanh toán gia hạn, vui lòng bỏ qua email này. Mọi thắc mắc xin liên hệ {{support_email}} hoặc {{support_phone}}.
+
+| Lưu ý: Đây là email được gửi tự động. Vui lòng không trả lời email này. |
+
+ |
+HTML,
+ ],
+ ];
+ }
+
+ private function header(): string
+ {
+ return <<<'HTML'
+
+
+HTML;
+ }
+
+ private function footer(): string
+ {
+ return <<<'HTML'
+
+ |
+HTML;
+ }
+};
diff --git a/database/migrations/2026_09_20_000002_add_received_amount_to_transactions_table.php b/database/migrations/2026_09_20_000002_add_received_amount_to_transactions_table.php
new file mode 100644
index 0000000..3a67142
--- /dev/null
+++ b/database/migrations/2026_09_20_000002_add_received_amount_to_transactions_table.php
@@ -0,0 +1,36 @@
+bigInteger('received_amount')->nullable()->after('amount')
+ ->comment('Số tiền thực nhận trên sao kê; null = chưa duyệt, khi duyệt mặc định bằng amount');
+ });
+
+ // Giao dịch đã duyệt trước đây được cộng đúng bằng `amount`, nên backfill để
+ // báo cáo đối soát không thấy một khoảng trống giả.
+ DB::table('transactions')
+ ->where('status', \App\Enums\TransactionStatus::COMPLETED->value)
+ ->whereNull('received_amount')
+ ->update(['received_amount' => DB::raw('amount')]);
+ }
+
+ public function down(): void
+ {
+ Schema::table('transactions', function (Blueprint $table) {
+ $table->dropColumn('received_amount');
+ });
+ }
+};
diff --git a/database/migrations/2026_09_20_000003_seed_invoice_payment_reminder_template.php b/database/migrations/2026_09_20_000003_seed_invoice_payment_reminder_template.php
new file mode 100644
index 0000000..720839f
--- /dev/null
+++ b/database/migrations/2026_09_20_000003_seed_invoice_payment_reminder_template.php
@@ -0,0 +1,103 @@
+where('scenario', self::SCENARIO)->exists()) {
+ return;
+ }
+
+ $variables = [
+ ['key' => 'client_name', 'label' => 'Tên khách hàng', 'type' => 'string', 'example' => 'Nguyễn Văn A'],
+ ['key' => 'invoice_number', 'label' => 'Số hoá đơn', 'type' => 'string', 'example' => 'INV-20260920-00001'],
+ ['key' => 'total', 'label' => 'Tổng tiền hoá đơn', 'type' => 'string', 'example' => '1.200.000'],
+ ['key' => 'outstanding', 'label' => 'Số tiền còn phải trả', 'type' => 'string', 'example' => '1.200.000'],
+ ['key' => 'due_date', 'label' => 'Hạn thanh toán', 'type' => 'string', 'example' => '20/10/2026'],
+ ['key' => 'status_line', 'label' => 'Câu mô tả tình trạng hạn', 'type' => 'string', 'example' => 'Hoá đơn sẽ đến hạn sau 7 ngày.'],
+ ['key' => 'paid_row', 'label' => '[Tự động] Dòng đã thanh toán một phần', 'type' => 'html', 'example' => ''],
+ ['key' => 'login_url', 'label' => 'Link đăng nhập', 'type' => 'url', 'example' => 'https://portal.tgs.com.vn/dang-nhap'],
+ ['key' => 'support_email', 'label' => 'Email hỗ trợ', 'type' => 'string', 'example' => 'support@tgs.com.vn'],
+ ['key' => 'support_phone', 'label' => 'Hotline hỗ trợ', 'type' => 'string', 'example' => '(028) 7309. 7379'],
+ ];
+
+ DB::table('template')->insert([
+ 'type_id' => 0,
+ 'scenario' => self::SCENARIO,
+ 'key' => self::SCENARIO,
+ 'format' => 'html',
+ 'name' => 'Nhắc thanh toán hoá đơn',
+ 'subject' => 'Nhắc thanh toán hoá đơn {{invoice_number}}',
+ 'message' => $this->header() . $this->body() . $this->footer(),
+ 'metadata' => json_encode(['variables' => $variables], JSON_UNESCAPED_UNICODE),
+ 'status' => 1,
+ 'created_at' => now(),
+ 'updated_at' => now(),
+ ]);
+ }
+
+ public function down(): void
+ {
+ DB::table('template')->where('scenario', self::SCENARIO)->delete();
+ }
+
+ private function body(): string
+ {
+ return <<<'HTML'
+|
+ Nhắc thanh toán hoá đơn
+Kính chào {{client_name}},
+{{status_line}} Quý khách vui lòng hoàn tất thanh toán để dịch vụ không bị gián đoạn.
+
+| Số hoá đơn: | {{invoice_number}} |
+| Tổng tiền: | {{total}} VNĐ |
+{{paid_row}}
+| Còn phải thanh toán: | {{outstanding}} VNĐ |
+| Hạn thanh toán: | {{due_date}} |
+
+
+Nếu Quý khách đã thanh toán, vui lòng bỏ qua email này. Mọi thắc mắc xin liên hệ {{support_email}} hoặc {{support_phone}}.
+
+| Lưu ý: Đây là email được gửi tự động. Vui lòng không trả lời email này. |
+
+ |
+HTML;
+ }
+
+ private function header(): string
+ {
+ return <<<'HTML'
+
+
+HTML;
+ }
+
+ private function footer(): string
+ {
+ return <<<'HTML'
+
+ |
+HTML;
+ }
+};
diff --git a/database/migrations/2026_09_20_000004_add_amount_paid_to_invoices_table.php b/database/migrations/2026_09_20_000004_add_amount_paid_to_invoices_table.php
new file mode 100644
index 0000000..7e04dfd
--- /dev/null
+++ b/database/migrations/2026_09_20_000004_add_amount_paid_to_invoices_table.php
@@ -0,0 +1,35 @@
+= total.
+ */
+return new class extends Migration
+{
+ public function up(): void
+ {
+ Schema::table('invoices', function (Blueprint $table) {
+ $table->unsignedBigInteger('amount_paid')->default(0)->after('total')
+ ->comment('Số tiền đã thu được cộng dồn, VND. PAID khi >= total');
+ });
+
+ // Hoá đơn đã PAID trước đây luôn được thu đủ (luồng cũ all-or-nothing), nên backfill
+ // để báo cáo công nợ không thấy toàn bộ lịch sử là "chưa thu đồng nào".
+ DB::table('invoices')
+ ->where('status', \App\Enums\Invoice\InvoiceStatus::PAID->value)
+ ->update(['amount_paid' => DB::raw('total')]);
+ }
+
+ public function down(): void
+ {
+ Schema::table('invoices', function (Blueprint $table) {
+ $table->dropColumn('amount_paid');
+ });
+ }
+};
diff --git a/database/migrations/2026_09_20_000005_widen_customer_balance_columns.php b/database/migrations/2026_09_20_000005_widen_customer_balance_columns.php
new file mode 100644
index 0000000..5a8e1bc
--- /dev/null
+++ b/database/migrations/2026_09_20_000005_widen_customer_balance_columns.php
@@ -0,0 +1,43 @@
+bigInteger('total')->default(0)->change();
+ $table->bigInteger('credit')->default(0)->change();
+ });
+
+ if (Schema::hasColumn('customers', 'total_pending')) {
+ Schema::table('customers', function (Blueprint $table) {
+ $table->bigInteger('total_pending')->default(0)->change();
+ });
+ }
+
+ Schema::table('transactions', function (Blueprint $table) {
+ $table->bigInteger('amount')->nullable()->change();
+ });
+ }
+
+ public function down(): void
+ {
+ Schema::table('customers', function (Blueprint $table) {
+ $table->integer('total')->default(0)->change();
+ $table->integer('credit')->default(0)->change();
+ });
+
+ Schema::table('transactions', function (Blueprint $table) {
+ $table->integer('amount')->nullable()->change();
+ });
+ }
+};
diff --git a/database/migrations/2026_09_20_000006_seed_customer_payment_and_invoice_notification_templates.php b/database/migrations/2026_09_20_000006_seed_customer_payment_and_invoice_notification_templates.php
new file mode 100644
index 0000000..1f36bb4
--- /dev/null
+++ b/database/migrations/2026_09_20_000006_seed_customer_payment_and_invoice_notification_templates.php
@@ -0,0 +1,113 @@
+templates() as $scenario => $item) {
+ if (DB::table('template')->where('scenario', $scenario)->exists()) {
+ continue;
+ }
+
+ DB::table('template')->insert([
+ 'type_id' => 0,
+ 'scenario' => $scenario,
+ 'key' => $scenario,
+ 'format' => 'html',
+ 'name' => $item['name'],
+ 'subject' => $item['subject'],
+ 'message' => EmailTemplateShell::wrap(EmailTemplateShell::body($item['body'])),
+ 'metadata' => json_encode(['variables' => $item['variables']], JSON_UNESCAPED_UNICODE),
+ 'status' => 1,
+ 'created_at' => now(),
+ 'updated_at' => now(),
+ ]);
+ }
+ }
+
+ public function down(): void
+ {
+ DB::table('template')->whereIn('scenario', self::SCENARIOS)->delete();
+ }
+
+ private function templates(): array
+ {
+ $notice = EmailTemplateShell::autoNotice();
+
+ return [
+ 'customer_payment' => [
+ 'name' => 'Thông báo thanh toán khách hàng',
+ 'subject' => 'Thông báo thanh toán - {{company_name}}',
+ 'variables' => [
+ ['key' => 'company_name', 'label' => 'Tên công ty', 'type' => 'string', 'example' => 'Thế Giới Số'],
+ ['key' => 'company_logo', 'label' => 'Logo công ty (URL)', 'type' => 'url', 'example' => ''],
+ ],
+ 'body' => <<Thông báo thanh toán
+Kính chào Quý khách,
+Cảm ơn Quý khách đã tin tưởng và sử dụng dịch vụ của {{company_name}}. Chúng tôi gửi email này để thông báo về tình trạng thanh toán trên tài khoản của Quý khách.
+Quý khách vui lòng đăng nhập vào cổng khách hàng để xem chi tiết hoá đơn và thực hiện thanh toán.
+Nếu Quý khách cần hỗ trợ, vui lòng liên hệ với chúng tôi để được giải đáp.
+{$notice}
+HTML,
+ ],
+
+ 'invoice_notification' => [
+ 'name' => 'Thông báo hoá đơn / gia hạn cước',
+ 'subject' => '{{title}} #{{invoice_number}} - {{company_name}}',
+ 'variables' => [
+ ['key' => 'title', 'label' => 'Tiêu đề (hoá đơn / gia hạn cước)', 'type' => 'string', 'example' => 'Thông báo hóa đơn'],
+ ['key' => 'company_name', 'label' => 'Tên công ty', 'type' => 'string', 'example' => 'Thế Giới Số'],
+ ['key' => 'customer_name', 'label' => 'Tên khách hàng', 'type' => 'string', 'example' => 'Nguyễn Văn A'],
+ ['key' => 'body_message', 'label' => 'Lời nhắn của admin', 'type' => 'string', 'example' => 'Quý khách vui lòng thanh toán hoá đơn đính kèm.'],
+ ['key' => 'invoice_number', 'label' => 'Số hoá đơn', 'type' => 'string', 'example' => 'INV-20260920-00001'],
+ ['key' => 'created_date', 'label' => 'Ngày tạo hoá đơn', 'type' => 'string', 'example' => '20/09/2026'],
+ ['key' => 'due_date', 'label' => 'Hạn thanh toán', 'type' => 'string', 'example' => '20/10/2026'],
+ ['key' => 'total', 'label' => 'Tổng tiền', 'type' => 'string', 'example' => '1.200.000'],
+ ['key' => 'status_label', 'label' => '[Tự động] Trạng thái hoá đơn', 'type' => 'html', 'example' => 'Chưa thanh toán'],
+ ['key' => 'outstanding_row', 'label' => '[Tự động] Dòng còn thiếu khi trả một phần', 'type' => 'html', 'example' => ''],
+ ['key' => 'intro_note', 'label' => 'Ghi chú về file PDF đính kèm', 'type' => 'string', 'example' => 'File PDF hóa đơn đã được đính kèm trong email này.'],
+ ['key' => 'pay_button', 'label' => '[Tự động] Nút thanh toán', 'type' => 'html', 'example' => ''],
+ ['key' => 'attachments_block', 'label' => '[Tự động] Danh sách file đính kèm', 'type' => 'html', 'example' => ''],
+ ['key' => 'note_block', 'label' => '[Tự động] Ghi chú trên hoá đơn', 'type' => 'html', 'example' => ''],
+ ],
+ 'body' => <<{{title}}
+Kính chào {{customer_name}},
+{{body_message}}
+
+| Số hóa đơn: | {{invoice_number}} |
+| Ngày tạo: | {{created_date}} |
+| Hạn thanh toán: | {{due_date}} |
+| Tổng tiền: | {{total}} VNĐ |
+{{outstanding_row}}
+| Trạng thái: | {{status_label}} |
+
+
+{{pay_button}}
+{{attachments_block}}
+{{note_block}}
+Nếu Quý khách có bất kỳ thắc mắc nào, vui lòng liên hệ với chúng tôi để được hỗ trợ.
+Trân trọng,
{{company_name}}
+{$notice}
+HTML,
+ ],
+ ];
+ }
+};
diff --git a/database/migrations/2026_09_20_000007_replace_blade_placeholders_in_templates.php b/database/migrations/2026_09_20_000007_replace_blade_placeholders_in_templates.php
new file mode 100644
index 0000000..c3035b2
--- /dev/null
+++ b/database/migrations/2026_09_20_000007_replace_blade_placeholders_in_templates.php
@@ -0,0 +1,87 @@
+companyInfo();
+
+ $map = [
+ 'companyName' => $setting['company'],
+ 'companyAddress' => $setting['address'],
+ 'companyTax' => $setting['tax'],
+ 'supportPhone' => $setting['hotline'],
+ 'supportEmail' => $setting['support'],
+ ];
+
+ $templates = DB::table('template')->select('id', 'subject', 'message')->get();
+ $updated = 0;
+
+ foreach ($templates as $template) {
+ $subject = $this->replace((string) $template->subject, $map);
+ $message = $this->replace((string) $template->message, $map);
+
+ if ($subject === (string) $template->subject && $message === (string) $template->message) {
+ continue;
+ }
+
+ DB::table('template')->where('id', $template->id)->update([
+ 'subject' => $template->subject === null ? null : $subject,
+ 'message' => $message,
+ 'updated_at' => now(),
+ ]);
+ $updated++;
+ }
+
+ // Không echo khi chạy trong test (RefreshDatabase) — làm bẩn output PHPUnit.
+ if ($updated > 0 && app()->runningInConsole() && !app()->runningUnitTests()) {
+ echo " Đã thay placeholder blade trong {$updated} template.\n";
+ }
+ }
+
+ /**
+ * Không có down(): đây là sửa dữ liệu hỏng, khôi phục lại placeholder blade chỉ làm
+ * email hiển thị sai trở lại.
+ */
+ public function down(): void
+ {
+ // no-op
+ }
+
+ private function replace(string $content, array $map): string
+ {
+ foreach ($map as $key => $value) {
+ $content = preg_replace('/\{\{\s*\$' . preg_quote($key, '/') . '\s*\}\}/', $value, $content) ?? $content;
+ }
+
+ return $content;
+ }
+
+ private function companyInfo(): array
+ {
+ $raw = DB::table('setting')->where('key', 'setting')->value('value');
+ $parsed = $raw ? (json_decode((string) $raw, true) ?: []) : [];
+
+ // DB test không có row setting nào → phải dùng ?? chứ không phải ?:, nếu không
+ // "Undefined array key" làm hỏng mọi test dùng RefreshDatabase.
+ return [
+ 'company' => $parsed['company'] ?? null ?: 'CÔNG TY CỔ PHẦN GIẢI PHÁP CÔNG NGHỆ THẾ GIỚI SỐ',
+ 'address' => $parsed['address'] ?? null ?: '128/38 Tân Hương, Phường Phú Thọ Hòa, Thành Phố Hồ Chí Minh',
+ 'tax' => $parsed['tax'] ?? null ?: '0318443116',
+ 'hotline' => $parsed['hotline'] ?? null ?: '(028) 7309. 7379',
+ 'support' => $parsed['support'] ?? null ?: 'support@tgs.com.vn',
+ ];
+ }
+};
diff --git a/database/migrations/2026_09_20_000008_hardcode_support_contact_in_templates.php b/database/migrations/2026_09_20_000008_hardcode_support_contact_in_templates.php
new file mode 100644
index 0000000..b8f9ae7
--- /dev/null
+++ b/database/migrations/2026_09_20_000008_hardcode_support_contact_in_templates.php
@@ -0,0 +1,72 @@
+contactInfo();
+
+ $map = [
+ 'support_phone' => $contact['hotline'],
+ 'support_email' => $contact['support'],
+ ];
+
+ $updated = 0;
+
+ foreach (DB::table('template')->select('id', 'subject', 'message')->get() as $template) {
+ $subject = $this->replace((string) $template->subject, $map);
+ $message = $this->replace((string) $template->message, $map);
+
+ if ($subject === (string) $template->subject && $message === (string) $template->message) {
+ continue;
+ }
+
+ DB::table('template')->where('id', $template->id)->update([
+ 'subject' => $template->subject === null ? null : $subject,
+ 'message' => $message,
+ 'updated_at' => now(),
+ ]);
+ $updated++;
+ }
+
+ if ($updated > 0 && app()->runningInConsole() && !app()->runningUnitTests()) {
+ echo " Đã ghi cứng thông tin liên hệ trong {$updated} template.\n";
+ }
+ }
+
+ /** Không khôi phục: đưa biến trở lại footer chỉ làm admin mất quyền sửa trực tiếp. */
+ public function down(): void
+ {
+ // no-op
+ }
+
+ private function replace(string $content, array $map): string
+ {
+ foreach ($map as $key => $value) {
+ $content = preg_replace('/\{\{\s*' . preg_quote($key, '/') . '\s*\}\}/', $value, $content) ?? $content;
+ }
+
+ return $content;
+ }
+
+ private function contactInfo(): array
+ {
+ $raw = DB::table('setting')->where('key', 'setting')->value('value');
+ $parsed = $raw ? (json_decode((string) $raw, true) ?: []) : [];
+
+ return [
+ 'hotline' => $parsed['hotline'] ?? null ?: '(028) 7309. 7379',
+ 'support' => $parsed['support'] ?? null ?: 'support@tgs.com.vn',
+ ];
+ }
+};
diff --git a/database/migrations/2026_09_23_000001_seed_customer_password_reset_link_template.php b/database/migrations/2026_09_23_000001_seed_customer_password_reset_link_template.php
new file mode 100644
index 0000000..16fe57c
--- /dev/null
+++ b/database/migrations/2026_09_23_000001_seed_customer_password_reset_link_template.php
@@ -0,0 +1,89 @@
+where('scenario', self::SCENARIO)->exists()) {
+ return;
+ }
+
+ $variables = [
+ ['key' => 'full_name', 'label' => 'Tên khách hàng', 'type' => 'string', 'example' => 'Nguyễn Văn A'],
+ ['key' => 'reset_url', 'label' => 'Link đặt lại mật khẩu', 'type' => 'url', 'example' => 'https://portal.tgs.com.vn/dat-lai-mat-khau?token=abc&email=a%40b.com'],
+ ['key' => 'expire_minutes', 'label' => 'Thời hạn link (phút)', 'type' => 'string', 'example' => '60'],
+ ];
+
+ DB::table('template')->insert([
+ 'type_id' => 0,
+ 'scenario' => self::SCENARIO,
+ 'key' => self::SCENARIO,
+ 'format' => 'html',
+ 'name' => 'Link đặt lại mật khẩu khách hàng',
+ 'subject' => 'Yêu cầu đặt lại mật khẩu',
+ 'message' => $this->header() . $this->body() . $this->footer(),
+ 'metadata' => json_encode(['variables' => $variables], JSON_UNESCAPED_UNICODE),
+ 'status' => 1,
+ 'created_at' => now(),
+ 'updated_at' => now(),
+ ]);
+ }
+
+ public function down(): void
+ {
+ DB::table('template')->where('scenario', self::SCENARIO)->delete();
+ }
+
+ private function body(): string
+ {
+ return <<<'HTML'
+|
+ Đặt lại mật khẩu
+Kính chào {{full_name}},
+Chúng tôi nhận được yêu cầu đặt lại mật khẩu cho tài khoản của Quý khách. Vui lòng bấm nút bên dưới để tạo mật khẩu mới. Link có hiệu lực trong {{expire_minutes}} phút và chỉ dùng được một lần.
+
+Nếu Quý khách không yêu cầu, vui lòng bỏ qua email này — mật khẩu hiện tại vẫn giữ nguyên. Mọi thắc mắc xin liên hệ support@tgs.com.vn hoặc (028) 7309. 7379.
+
+| Lưu ý: Không chia sẻ link này cho bất kỳ ai, kể cả nhân viên Thế Giới Số. |
+
+ |
+HTML;
+ }
+
+ private function header(): string
+ {
+ return <<<'HTML'
+
+
+HTML;
+ }
+
+ private function footer(): string
+ {
+ return <<<'HTML'
+
+ |
+HTML;
+ }
+};
diff --git a/database/migrations/2026_09_23_000002_move_cccd_images_to_private_disk.php b/database/migrations/2026_09_23_000002_move_cccd_images_to_private_disk.php
new file mode 100644
index 0000000..e7ba87d
--- /dev/null
+++ b/database/migrations/2026_09_23_000002_move_cccd_images_to_private_disk.php
@@ -0,0 +1,74 @@
+where(fn ($q) => $q->where('front_identity_number', 'like', '%/storage/cccd/%')
+ ->orWhere('back_identity_number', 'like', '%/storage/cccd/%'))
+ ->orderBy('id')
+ ->each(function ($row) {
+ $updates = [];
+
+ foreach (self::COLUMNS as $column) {
+ $value = (string) $row->{$column};
+ if (!str_contains($value, '/storage/cccd/')) {
+ continue;
+ }
+
+ $path = 'cccd/' . Str::afterLast($value, '/storage/cccd/');
+ if (Storage::disk('public')->exists($path)) {
+ Storage::disk('local')->put($path, Storage::disk('public')->get($path));
+ Storage::disk('public')->delete($path);
+ }
+ $updates[$column] = $path;
+ }
+
+ DB::table('customers')->where('id', $row->id)->update($updates);
+ });
+
+ // Ảnh mồ côi (khách đã upload lại, DB không còn trỏ tới) vẫn là CCCD thật → không để public.
+ foreach (Storage::disk('public')->files('cccd') as $path) {
+ Storage::disk('local')->put($path, Storage::disk('public')->get($path));
+ Storage::disk('public')->delete($path);
+ }
+ }
+
+ public function down(): void
+ {
+ DB::table('customers')
+ ->where(fn ($q) => $q->where('front_identity_number', 'like', 'cccd/%')
+ ->orWhere('back_identity_number', 'like', 'cccd/%'))
+ ->orderBy('id')
+ ->each(function ($row) {
+ $updates = [];
+
+ foreach (self::COLUMNS as $column) {
+ $path = (string) $row->{$column};
+ if (!str_starts_with($path, 'cccd/')) {
+ continue;
+ }
+
+ if (Storage::disk('local')->exists($path)) {
+ Storage::disk('public')->put($path, Storage::disk('local')->get($path));
+ Storage::disk('local')->delete($path);
+ }
+ $updates[$column] = Storage::disk('public')->url($path);
+ }
+
+ DB::table('customers')->where('id', $row->id)->update($updates);
+ });
+ }
+};
diff --git a/database/seeders/TemplateSeeder.php b/database/seeders/TemplateSeeder.php
index 97df7c9..4b487ee 100644
--- a/database/seeders/TemplateSeeder.php
+++ b/database/seeders/TemplateSeeder.php
@@ -263,17 +263,6 @@ HTML
'type_id' => 0,
'status' => 1,
],
- [
- 'name' => 'Thông báo nghỉ lễ',
- 'scenario' => 'holiday_notice',
- 'format' => 'html',
- 'subject' => 'Thông báo lịch nghỉ lễ từ {$holiday_name}',
- 'message' => <<<'HTML'
- Kính gửi: Quý Khách hàng, Quý Đối tác Nhân dịp kỷ niệm {$holiday_name}, Thế Giới Số xin trân trọng gửi tới Quý Khách hàng và Quý Đối tác lời chúc sức khỏe, hạnh phúc và thành công. Thế Giới Số xin thông báo lịch nghỉ lễ như sau: Thời gian nghỉ lễ: Từ {$holiday_from} đến hết {$holiday_to} Thời gian làm việc trở lại: {$work_again} Trong thời gian nghỉ lễ, nếu có vấn đề cần hỗ trợ kỹ thuật vui lòng liên hệ: + Hotline: {$hotline} Kính chúc Quý Khách hàng và Quý Đối tác có một kỳ nghỉ lễ vui vẻ, an toàn và nhiều sức khỏe. |
|
-HTML,
- 'type_id' => 0,
- 'status' => 1,
- ],
];
foreach ($newTemplates as $item) {
diff --git a/database/seeders/TestAccountSeeder.php b/database/seeders/TestAccountSeeder.php
new file mode 100644
index 0000000..c63d75f
--- /dev/null
+++ b/database/seeders/TestAccountSeeder.php
@@ -0,0 +1,90 @@
+seedAdmin();
+ $this->seedCustomer();
+ }
+
+ private function seedAdmin(): void
+ {
+ $admin = User::where('username', self::ADMIN_USERNAME)->first();
+
+ if ($admin) {
+ $admin->update(['password' => Hash::make(self::ADMIN_PASSWORD)]);
+ $this->command?->info('Admin đã có — đặt lại mật khẩu cho "' . self::ADMIN_USERNAME . '".');
+ } else {
+ $admin = User::create([
+ 'fullname' => 'System Admin',
+ 'username' => self::ADMIN_USERNAME,
+ 'email' => 'admin@tgs.com.vn',
+ 'email_verified_at' => now(),
+ 'password' => Hash::make(self::ADMIN_PASSWORD),
+ ]);
+ $this->command?->info('Đã tạo admin "' . self::ADMIN_USERNAME . '".');
+ }
+
+ // status nằm ngoài $fillable nên phải gán thẳng.
+ $admin->forceFill(['status' => 1])->save();
+
+ if (method_exists($admin, 'syncRoles') && \Spatie\Permission\Models\Role::where('name', 'Full Administrator')->exists()) {
+ $admin->syncRoles(['Full Administrator']);
+ $this->command?->info(' → gán role Full Administrator.');
+ }
+ }
+
+ private function seedCustomer(): void
+ {
+ $customer = Customer::where('email', self::CUSTOMER_EMAIL)->first();
+
+ if ($customer) {
+ $customer->update(['password' => Hash::make(self::CUSTOMER_PASSWORD)]);
+ $this->command?->info('Khách hàng đã có — đặt lại mật khẩu cho ' . self::CUSTOMER_EMAIL . '.');
+ } else {
+ $customer = Customer::create([
+ 'fullname' => 'Phạm Bảo (test)',
+ 'username' => 'pbao.dev',
+ 'email' => self::CUSTOMER_EMAIL,
+ 'phone' => '0900000000',
+ 'password' => Hash::make(self::CUSTOMER_PASSWORD),
+ 'email_verified_at' => now(),
+ 'email_verify' => 1,
+ 'address' => '128/38 Tân Hương, Phú Thọ Hòa, TP. Hồ Chí Minh',
+ ]);
+ $this->command?->info('Đã tạo khách hàng ' . self::CUSTOMER_EMAIL . '.');
+ }
+
+ // Nạp sẵn số dư để đặt và thanh toán đơn thử, kể cả các ca trả từng phần.
+ $customer->forceFill([
+ 'status' => 1,
+ 'total' => max((int) $customer->total, 50_000_000),
+ ])->save();
+
+ if (empty($customer->uuid)) {
+ $customer->forceFill(['uuid' => (string) Str::uuid()])->save();
+ }
+
+ $this->command?->info(' → số dư: ' . number_format((int) $customer->fresh()->total) . ' VNĐ');
+ }
+}
diff --git a/phpunit.xml b/phpunit.xml
index 1f7e045..239ecfb 100644
--- a/phpunit.xml
+++ b/phpunit.xml
@@ -21,6 +21,7 @@
+
diff --git a/resources/views/emails/customer/send-info-vps.blade.php b/resources/views/emails/customer/send-info-vps.blade.php
deleted file mode 100644
index 6cf3d00..0000000
--- a/resources/views/emails/customer/send-info-vps.blade.php
+++ /dev/null
@@ -1,218 +0,0 @@
-@php
- $companyName = $companyName ?? config('app.name', 'Thế Giới Số');
- $companyLogo = $companyLogo ?? '';
- $clientName = $clientName ?? 'Quý khách';
- $productName = $productName ?? 'Cloud VPS';
- $productBilling = $productBilling ?? '';
- $productPrice = $productPrice ?? '';
- $serviceHost = $serviceHost ?? '';
- $serviceUsername = $serviceUsername ?? '';
- $servicePort = $servicePort ?? '';
- $servicePassword = $servicePassword ?? '';
- $osName = $osName ?? '';
- $serviceDueExp = $serviceDueExp ?? '';
- $loginUrl = $loginUrl ?? rtrim(config('app.frontend_url', ''), '/') . '/dang-nhap';
- $supportEmail = $supportEmail ?? 'support@tgs.com.vn';
- $supportPhone = $supportPhone ?? '(028) 7309. 7379';
-@endphp
-
-
-
-
-
-
-
-
-
- |
-
-
-
-
-
- VPS của bạn đã sẵn sàng
-
-
-
- Kính chào {{ $clientName }},
-
-
-
- Dịch vụ {{ $productName }} tại {{ $companyName }} đã được khởi tạo thành công. Dưới đây là thông tin truy cập:
-
-
-
-
-
- | Gói dịch vụ: |
- {{ $productName }} |
-
- @if($productBilling)
-
- | Chu kỳ thanh toán: |
- {{ $productBilling }} |
-
- @endif
- @if($productPrice)
-
- | Giá dịch vụ: |
- {{ $productPrice }} VNĐ |
-
- @endif
- @if($osName)
-
- | Hệ điều hành: |
- {{ $osName }} |
-
- @endif
-
- | Địa chỉ IP: |
- {{ $serviceHost }} |
-
-
- | Tài khoản đăng nhập: |
- {{ $serviceUsername }} |
-
-
- | Mật khẩu: |
- {{ $servicePassword }} |
-
- @if($servicePort)
-
- | Cổng kết nối: |
- {{ $servicePort }} |
-
- @endif
- @if($serviceDueExp)
-
- | Hạn sử dụng: |
- {{ $serviceDueExp }} |
-
- @endif
-
- | Trạng thái: |
- Đang hoạt động ✓ |
-
-
-
-
-
- Lưu ý: Vì lý do bảo mật, mật khẩu chỉ được gửi một lần duy nhất qua email và không được lưu trữ trên hệ thống. Vui lòng lưu lại ở nơi an toàn.
-
-
-
- Bạn có thể đăng nhập vào cổng quản lý để theo dõi và quản lý dịch vụ của mình.
-
-
-
-
-
- Nếu bạn không yêu cầu dịch vụ này, vui lòng liên hệ ngay với chúng tôi qua {{ $supportEmail }} hoặc {{ $supportPhone }}.
-
-
-
- Email này được gửi tự động. Vui lòng không trả lời email này.
-
-
- ---
- Hotline: {{ $supportPhone }}
- Email: {{ $supportEmail }}
-
- |
-
-
-
-
- |
-
-
-
diff --git a/resources/views/emails/customer/template-camp.blade.php b/resources/views/emails/customer/template-camp.blade.php
deleted file mode 100644
index 3edfe20..0000000
--- a/resources/views/emails/customer/template-camp.blade.php
+++ /dev/null
@@ -1,29 +0,0 @@
-
-
-
-
-
- {{ $emailSubject ?? '' }}
-
-
-
-
-
-
-
-
- @include('emails.partials.email-header')
-
- |
- {!! $content !!}
- |
-
- @include('emails.partials.email-footer')
-
-
- |
-
-
-
-
-
diff --git a/resources/views/emails/customer/template-email.blade.php b/resources/views/emails/customer/template-email.blade.php
deleted file mode 100644
index bf8e36c..0000000
--- a/resources/views/emails/customer/template-email.blade.php
+++ /dev/null
@@ -1,178 +0,0 @@
-@php
- $companyName = $companyName ?? (setting('setting.company') ?: 'Công Ty Cổ Phần Giải Pháp Công Nghệ Thế Giới Số');
- $headerBannerUrl = $headerBannerUrl ?? (setting('setting.email_header_banner') ?: 'https://res.cloudinary.com/izpast9i/image/upload/v1786499308/email_header.png');
-@endphp
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- |
-
-
-
-
- Có đơn đăng ký tên miền mới 🎉
-
- Một khách hàng vừa đăng ký tên miền tại {{company_name}}. Chi tiết đơn hàng bên dưới.
-
-
-
-
-
-
-
-
- | Tên miền |
- {{domain_name}} |
-
-
- | Mã đơn hàng |
- {{order_num}} |
-
-
- | Tổng tiền |
- {{order_total}} |
-
-
- | Khách hàng |
- {{customer_name}} |
-
-
- | Email |
- {{customer_email}} |
-
-
- | Thời gian |
- {{created_at}} |
-
-
- |
-
-
-
-
-
-
-
- 💡 Đây là tin nhắn tự động, vui lòng không trả lời email này.
-
- |
-
- |
-
- |
-
-
-
-
-
-
-
- |
-
-
-
diff --git a/resources/views/emails/customer/template-envelope.blade.php b/resources/views/emails/customer/template-envelope.blade.php
deleted file mode 100644
index 2a3882c..0000000
--- a/resources/views/emails/customer/template-envelope.blade.php
+++ /dev/null
@@ -1,11 +0,0 @@
-
-
-
-
-
- {{ $emailSubject ?? '' }}
-
-
-{!! $body !!}
-
-
diff --git a/resources/views/emails/customer/welcome.blade.php b/resources/views/emails/customer/welcome.blade.php
deleted file mode 100644
index 7e83360..0000000
--- a/resources/views/emails/customer/welcome.blade.php
+++ /dev/null
@@ -1,83 +0,0 @@
-@php
- $appName = $appName ?? config('app.name', 'Thế Giới Số');
- $fullName = $fullName ?? 'Quý khách';
- $loginUrl = $loginUrl ?? rtrim(config('app.frontend_url', ''), '/') . '/dang-nhap';
- $supportEmail = $supportEmail ?? 'support@tgs.com.vn';
- $supportPhone = $supportPhone ?? '(028) 7309. 7379';
-@endphp
-
-
-
-
-
-
-
- @include('emails.partials.email-header')
-
- |
-
-
-
-
-
- Chào mừng Quý Khách đến với hệ thống quản trị dịch vụ của Thế Giới Số
-
-
-
- Kính chào {{ $fullName }},
-
-
-
- Chào mừng Quý khách đến với {{ $appName }}. Tài khoản của Quý khách đã được khởi tạo thành công và sẵn sàng sử dụng. Chúng tôi rất trân trọng và vinh dự khi được đồng hành cùng Quý khách trên hành trình chuyển đổi số và tối ưu hóa hoạt động doanh nghiệp.
-
-
- TẠI {{ strtoupper($appName) }}, QUÝ KHÁCH CÓ THỂ
-
-
-
-
- | Khám phá và đăng ký các dịch vụ Cloud VPS, Hosting, Tên miền |
-
-
- | Đặt hàng nhanh chóng, thanh toán an toàn |
-
-
- | Theo dõi lịch sử đơn hàng và quản lý dịch vụ dễ dàng |
-
-
- | Nhận ưu đãi và khuyến mãi độc quyền dành cho khách hàng |
-
-
-
-
-
-
-
- Cảm ơn Quý khách đã tin tưởng và đồng hành cùng {{ $appName }}. Nếu có bất kỳ câu hỏi nào hoặc cần hỗ trợ, xin vui lòng liên hệ với chúng tôi.
-
-
-
- Email này được gửi tự động. Vui lòng không trả lời email này.
-
-
- ---
- Hotline: {{ $supportPhone }}
- Email: {{ $supportEmail }}
-
- |
-
- @include('emails.partials.email-footer')
-
-
- |
-
-
-
diff --git a/resources/views/emails/invoice-notification.blade.php b/resources/views/emails/invoice-notification.blade.php
deleted file mode 100644
index eae131c..0000000
--- a/resources/views/emails/invoice-notification.blade.php
+++ /dev/null
@@ -1,145 +0,0 @@
-
-
-
-
-
- {{ ($type ?? 'invoice') === 'renewal' ? 'Thông báo gia hạn cước' : 'Thông báo hóa đơn' }}
-
-
-
-@php
- $formatMailDate = static function ($value): string {
- if (empty($value)) {
- return '—';
- }
-
- if ($value instanceof \Carbon\CarbonInterface) {
- return $value->format('d/m/Y');
- }
-
- if (is_numeric($value)) {
- return date('d/m/Y', (int) $value);
- }
-
- try {
- return \Carbon\Carbon::parse($value)->format('d/m/Y');
- } catch (\Throwable) {
- return '—';
- }
- };
-@endphp
-
-
-
-
-
Xin chào {{ $customerName }},
-
{{ $bodyMessage ?? '' }}
-
-
-
-
- | Số hóa đơn |
- #{{ $invoice->order_num }} |
-
-
- | Ngày tạo |
- {{ $formatMailDate($invoice->date) }} |
-
-
- | Hạn thanh toán |
- {{ $formatMailDate($invoice->due_date) }} |
-
-
- | Tổng tiền |
- {{ number_format($invoice->total) }} VNĐ |
-
-
- | Trạng thái |
-
- @if($invoice->status == 2)
- Đã thanh toán
- @elseif($invoice->status == 1)
- Chưa thanh toán
- @else
- Đã hủy
- @endif
- |
-
-
-
-
-
- @if(($type ?? 'invoice') === 'renewal')
- File PDF hóa đơn gia hạn đã được đính kèm trong email này. Vui lòng hoàn tất thanh toán trước hạn để dịch vụ không bị gián đoạn.
- @else
- File PDF hóa đơn đã được đính kèm trong email này để quý khách tiện lưu trữ và đối chiếu.
- @endif
-
-
- @if(!empty($paymentUrl))
-
-
- Hoặc sao chép liên kết thanh toán:
- {{ $paymentUrl }}
-
- @endif
-
- @if($attachments->isNotEmpty())
-
- @endif
-
- @if($invoice->note)
-
- Ghi chú: {{ $invoice->note }}
-
- @endif
-
-
Nếu bạn có bất kỳ thắc mắc nào, vui lòng liên hệ với chúng tôi để được hỗ trợ.
-
Trân trọng,
{{ $companyName }}
-
-
-
-
-
-
diff --git a/resources/views/emails/layouts/notification.blade.php b/resources/views/emails/layouts/notification.blade.php
deleted file mode 100644
index 27cf2c9..0000000
--- a/resources/views/emails/layouts/notification.blade.php
+++ /dev/null
@@ -1,36 +0,0 @@
-@php
- $companyName = $companyName ?? config('app.name', 'Thế Giới Số');
- $title = $title ?? '';
- $bannerUrl = $bannerUrl ?? null;
-@endphp
-
-
-
-
-
-
- @include('emails.partials.email-header', ['companyName' => $companyName])
-
- |
- @if($bannerUrl)
-
-
-
- @endif
-
- @if($title)
-
- {{ $title }}
-
- @endif
-
- {!! $body !!}
- |
-
- @include('emails.partials.email-footer', ['companyName' => $companyName])
-
-
- |
-
-
-
diff --git a/resources/views/emails/partials/attachments.blade.php b/resources/views/emails/partials/attachments.blade.php
deleted file mode 100644
index c989f00..0000000
--- a/resources/views/emails/partials/attachments.blade.php
+++ /dev/null
@@ -1,15 +0,0 @@
-@if(($attachments ?? collect())->isNotEmpty())
-FILE ĐÍNH KÈM
-
-
-@endif
diff --git a/resources/views/emails/partials/custom-text.blade.php b/resources/views/emails/partials/custom-text.blade.php
deleted file mode 100644
index e5763e6..0000000
--- a/resources/views/emails/partials/custom-text.blade.php
+++ /dev/null
@@ -1,9 +0,0 @@
-@if(!empty($content))
-
- @if(strip_tags($content) === $content)
- {{ $content }}
- @else
- {!! $content !!}
- @endif
-
-@endif
diff --git a/resources/views/emails/partials/customer-info.blade.php b/resources/views/emails/partials/customer-info.blade.php
deleted file mode 100644
index 39abb10..0000000
--- a/resources/views/emails/partials/customer-info.blade.php
+++ /dev/null
@@ -1,23 +0,0 @@
-@php
- $fieldMap = [
- 'name' => 'Tên khách hàng',
- 'email' => 'Email',
- 'address' => 'Địa chỉ',
- ];
- $rows = collect($fields ?? array_keys($fieldMap))->filter(fn($key) => isset($fieldMap[$key]))->values();
- $propertyMap = ['name' => 'fullname', 'email' => 'email', 'address' => 'address'];
-@endphp
-@if($customer)
-THÔNG TIN KHÁCH HÀNG
-
-
-
- @foreach($rows as $i => $key)
-
- | {{ $fieldMap[$key] }}: |
- {{ $customer->{$propertyMap[$key]} ?? '—' }} |
-
- @endforeach
-
-
-@endif
diff --git a/resources/views/emails/partials/email-footer.blade.php b/resources/views/emails/partials/email-footer.blade.php
deleted file mode 100644
index d71caa4..0000000
--- a/resources/views/emails/partials/email-footer.blade.php
+++ /dev/null
@@ -1,75 +0,0 @@
-@php
- $supportEmail = $supportEmail ?? (setting('setting.support') ?: 'support@tgs.com.vn');
- $supportPhone = $supportPhone ?? (setting('setting.hotline') ?: '(028) 7309. 7379');
- $companyName = $companyName ?? (setting('setting.company') ?: 'CÔNG TY CỔ PHẦN GIẢI PHÁP CÔNG NGHỆ THẾ GIỚI SỐ');
- $companyAddress = $companyAddress ?? (setting('setting.address') ?: '128/38 Tân Hương, Phường Phú Thọ Hòa, Thành Phố Hồ Chí Minh');
- $companyTax = $companyTax ?? (setting('setting.tax') ?: '0318443116');
-@endphp
-
diff --git a/resources/views/emails/partials/email-header.blade.php b/resources/views/emails/partials/email-header.blade.php
deleted file mode 100644
index 3c064af..0000000
--- a/resources/views/emails/partials/email-header.blade.php
+++ /dev/null
@@ -1,19 +0,0 @@
-@php
- $companyName = $companyName ?? (setting('setting.company') ?: 'Công Ty Cổ Phần Giải Pháp Công Nghệ Thế Giới Số');
- $headerBannerUrl = $headerBannerUrl ?? (setting('setting.email_header_banner') ?: 'https://res.cloudinary.com/izpast9i/image/upload/v1786499308/email_header.png');
-@endphp
-
diff --git a/resources/views/emails/partials/greeting.blade.php b/resources/views/emails/partials/greeting.blade.php
deleted file mode 100644
index 63b0977..0000000
--- a/resources/views/emails/partials/greeting.blade.php
+++ /dev/null
@@ -1 +0,0 @@
-Xin chào {{ $name ?? 'Quý khách' }},
diff --git a/resources/views/emails/partials/invoice-info.blade.php b/resources/views/emails/partials/invoice-info.blade.php
deleted file mode 100644
index 494f916..0000000
--- a/resources/views/emails/partials/invoice-info.blade.php
+++ /dev/null
@@ -1,33 +0,0 @@
-@php
- $rowFor = function (string $field) use ($invoice) {
- return match ($field) {
- 'invoice_number' => ['Số hóa đơn', '#' . ($invoice->invoice_number ?? '—')],
- 'created_at' => ['Ngày tạo', $invoice->created_at ? $invoice->created_at->format('d/m/Y') : '—'],
- 'due_date' => ['Hạn thanh toán', $invoice->due_date ? date('d/m/Y', strtotime($invoice->due_date)) : '—'],
- 'total' => ['Tổng tiền', number_format((float) $invoice->total) . ' VNĐ'],
- 'sub_total' => ['Tiền dịch vụ', number_format((float) $invoice->sub_total) . ' VNĐ'],
- 'tax' => ['Thuế', number_format((float) $invoice->tax) . ' VNĐ'],
- 'status' => ['Trạng thái', match ((int) $invoice->status) {
- 2 => 'Đã thanh toán',
- 1 => 'Chưa thanh toán',
- default => 'Đã hủy',
- }],
- 'payment_method' => ['Phương thức TT', $invoice->payment_method ?? '—'],
- default => [$field, '—'],
- };
- };
- $fields = $fields ?? ['invoice_number', 'created_at', 'due_date', 'total', 'status'];
-@endphp
-THÔNG TIN HÓA ĐƠN
-
-
-
- @foreach($fields as $i => $field)
- @php [$label, $value] = $rowFor($field); @endphp
-
- | {{ $label }}: |
- {!! $value !!} |
-
- @endforeach
-
-
diff --git a/resources/views/emails/partials/order-items.blade.php b/resources/views/emails/partials/order-items.blade.php
deleted file mode 100644
index 5019f65..0000000
--- a/resources/views/emails/partials/order-items.blade.php
+++ /dev/null
@@ -1,30 +0,0 @@
-@php
- $cycleMap = [
- 'monthly' => 'Hàng tháng', 'quarterly' => 'Hàng quý',
- 'semi_annually' => '6 tháng', 'annually' => 'Hàng năm', 'biennially' => '2 năm',
- ];
-@endphp
-@if(($items ?? collect())->isNotEmpty())
-CHI TIẾT SẢN PHẨM
-
-
-
-
- | Sản phẩm |
- Server |
- Chu kỳ |
- Giá |
-
-
-
- @foreach($items as $item)
-
- | {{ $item->product?->name ?? '—' }} |
- {{ $item->server_name ?? '—' }} |
- {{ $cycleMap[$item->billing_cycle] ?? ($item->billing_cycle ?? '—') }} |
- {{ number_format((float) $item->price) }} VNĐ |
-
- @endforeach
-
-
-@endif
diff --git a/resources/views/emails/partials/product-info.blade.php b/resources/views/emails/partials/product-info.blade.php
deleted file mode 100644
index 90287a5..0000000
--- a/resources/views/emails/partials/product-info.blade.php
+++ /dev/null
@@ -1,32 +0,0 @@
-@php
- $fieldMap = [
- 'product_name' => 'Tên dịch vụ',
- 'product_billing' => 'Chu kỳ thanh toán',
- 'product_price' => 'Giá dịch vụ',
- 'service_due_exp' => 'Ngày hết hạn',
- ];
- $iconMap = [
- 'product_billing' => 'buy-date.png',
- 'service_due_exp' => 'due-date.png',
- ];
- $rows = collect($fields ?? array_keys($fieldMap))->filter(fn($key) => isset($fieldMap[$key]))->values();
-@endphp
-THÔNG TIN DỊCH VỤ
-
-
-
- @foreach($rows as $i => $key)
-
-
- @if(isset($iconMap[$key]))
-
- @endif
- {{ $fieldMap[$key] }}:
- |
-
- {{ $key === 'product_price' ? ($data[$key] ?? '0') . ' VNĐ' : ($data[$key] ?? '—') }}
- |
-
- @endforeach
-
-
diff --git a/resources/views/emails/partials/signature.blade.php b/resources/views/emails/partials/signature.blade.php
deleted file mode 100644
index 3518fa3..0000000
--- a/resources/views/emails/partials/signature.blade.php
+++ /dev/null
@@ -1 +0,0 @@
-Trân trọng,
{{ $companyName ?? config('app.name', 'Thế Giới Số') }}
diff --git a/resources/views/emails/partials/vm-credentials.blade.php b/resources/views/emails/partials/vm-credentials.blade.php
deleted file mode 100644
index b39eafb..0000000
--- a/resources/views/emails/partials/vm-credentials.blade.php
+++ /dev/null
@@ -1,38 +0,0 @@
-@php
- $fieldMap = [
- 'service_host' => 'Địa chỉ IP',
- 'service_username' => 'Username',
- 'service_port' => 'Port',
- 'service_password' => 'Mật khẩu',
- 'os_name' => 'Hệ điều hành',
- ];
- $iconMap = [
- 'service_host' => 'internet.png',
- 'service_username' => 'admin.png',
- 'service_password' => 'password.png',
- ];
- $rows = collect($fields ?? array_keys($fieldMap))->filter(fn($key) => isset($fieldMap[$key]))->values();
-@endphp
-THÔNG TIN TRUY CẬP VPS
-
-
-
- @foreach($rows as $i => $key)
-
-
- @if(isset($iconMap[$key]))
-
- @endif
- {{ $fieldMap[$key] }}:
- |
-
- @if($key === 'service_password')
- {{ $data[$key] ?? '—' }}
- @else
- {{ $data[$key] ?? '—' }}
- @endif
- |
-
- @endforeach
-
-
diff --git a/routes/api.php b/routes/api.php
index 65b72d4..6b721bc 100644
--- a/routes/api.php
+++ b/routes/api.php
@@ -33,7 +33,6 @@ use App\Http\Controllers\Api\Admin\Hosting\ServicePlanController;
use App\Http\Controllers\Api\Admin\IpPool\IpPoolController;
use App\Http\Controllers\Api\Admin\Bank\BankController;
use App\Http\Controllers\Api\Admin\EmailTemplate\EmailTemplateController;
-use App\Http\Controllers\Api\Admin\EmailNotificationTemplateController;
use App\Http\Controllers\Api\Admin\ActivityLog\ActivityLogController;
use App\Http\Controllers\Api\Admin\Rebuild\AdminReinstallLogController;
use App\Http\Controllers\Api\Admin\EmailLog\EmailLogController;
@@ -323,6 +322,8 @@ Route::prefix('v1')
Route::delete('{id}', 'destroy')->middleware('perm:invoice.destroy');
Route::post('{id}/paid', 'markAsPaid')->middleware('perm:invoice.edit');
Route::post('{id}/cancel', 'cancel')->middleware('perm:invoice.edit');
+ Route::post('{id}/remind', 'remind')->middleware('perm:invoice.edit');
+ Route::post('{id}/refund', 'refund')->middleware('perm:invoice.edit');
// Attachments
Route::get('{id}/attachments', 'listAttachments')->middleware('perm:invoice.view');
Route::post('{id}/attachments', 'uploadAttachment')->middleware('perm:invoice.edit');
@@ -525,6 +526,15 @@ Route::prefix('v1')
Route::post('test-telegram', 'testTelegram');
});
+ // VAT settings per service type (module: settings)
+ Route::prefix('vat-settings')
+ ->controller(AdminSettingController::class)
+ ->middleware('perm:setting.manager')
+ ->group(function () {
+ Route::get('/', 'vatIndex');
+ Route::put('/', 'vatUpdate');
+ });
+
Route::prefix('media')
->controller(AdminMediaController::class)
->group(function () {
@@ -629,20 +639,6 @@ Route::prefix('v1')
Route::post('{id}/send-test', 'sendTest');
});
- // Notification Email Templates — block-based editor
- Route::prefix('notification-templates')
- ->controller(EmailNotificationTemplateController::class)
- ->middleware('perm:email-template.manager')
- ->group(function () {
- Route::get('/', 'index');
- Route::post('/', 'store');
- Route::get('field-definitions', 'fieldDefinitions');
- Route::get('{id}', 'show');
- Route::put('{id}', 'update');
- Route::delete('{id}', 'destroy');
- Route::get('{id}/preview', 'preview');
- });
-
// Transactions — manual deposit review (module: transaction)
Route::prefix('transactions')
->controller(AdminTransactionController::class)
@@ -756,6 +752,9 @@ Route::prefix('v1')
});
});
+ // Public branding settings — không cần token, dùng cho trang đăng nhập.
+ Route::get('settings/public', [AdminSettingController::class, 'public']);
+
Route::prefix('system')
->middleware(['api.auth'])
->group(function () {
diff --git a/routes/client.php b/routes/client.php
index fbd98a9..4fa28f8 100644
--- a/routes/client.php
+++ b/routes/client.php
@@ -31,12 +31,19 @@ Route::prefix('v1')
->as('client.')
->group(function () {
Route::controller(ClientController::class)->group(function () {
- Route::post('login', 'login')->middleware('throttle:5,1')->name('login');
- Route::post('register', 'register')->name('register');
- Route::post('verify-email', 'verifyEmail')->name('verify-email');
- Route::post('resend-verify', 'resendVerifyCode')->name('resend-verify');
+ Route::post('login', 'login')->middleware('throttle:client-auth')->name('login');
+ Route::post('register', 'register')->middleware('throttle:client-auth')->name('register');
+ Route::post('verify-email', 'verifyEmail')->middleware('throttle:client-auth')->name('verify-email');
+ Route::post('resend-verify', 'resendVerifyCode')->middleware('throttle:client-auth')->name('resend-verify');
Route::post('refresh', 'refresh')->name('refresh');
- Route::post('forgot-password', 'forgotPassword')->name('forgot-password');
+ Route::post('forgot-password', 'forgotPassword')->middleware('throttle:client-auth')->name('forgot-password');
+ Route::post('reset-password', 'resetPassword')->middleware('throttle:client-auth')->name('reset-password');
+ // Ảnh CCCD nằm trên disk private, chỉ truy cập qua URL ký (ClientResource cấp, hết hạn 60 phút)
+ Route::get('cccd/{customer}/{side}', 'cccdImage')
+ ->whereNumber('customer')
+ ->whereIn('side', ['front', 'back'])
+ ->middleware('signed:relative')
+ ->name('cccd');
Route::post('impersonate/exchange', 'impersonateExchange')->name('impersonate.exchange');
Route::group(['middleware' => ['api.auth']], function () {
diff --git a/storage/app/public/cccd/9TDwwNooyCkrHLhiKZGDGTWXPxmr2EC9fyOMX1gM.jpg b/storage/app/cccd/9TDwwNooyCkrHLhiKZGDGTWXPxmr2EC9fyOMX1gM.jpg
similarity index 100%
rename from storage/app/public/cccd/9TDwwNooyCkrHLhiKZGDGTWXPxmr2EC9fyOMX1gM.jpg
rename to storage/app/cccd/9TDwwNooyCkrHLhiKZGDGTWXPxmr2EC9fyOMX1gM.jpg
diff --git a/storage/app/cccd/MFeBKTQulEZ4oQ3viOaFslEWtxAiCc6nzPPFirfU.png b/storage/app/cccd/MFeBKTQulEZ4oQ3viOaFslEWtxAiCc6nzPPFirfU.png
new file mode 100644
index 0000000..b7b9dec
Binary files /dev/null and b/storage/app/cccd/MFeBKTQulEZ4oQ3viOaFslEWtxAiCc6nzPPFirfU.png differ
diff --git a/storage/app/cccd/bCVgnJtuC1WdESEGCapCyBsXmwPyzZHHLuFHahKc.png b/storage/app/cccd/bCVgnJtuC1WdESEGCapCyBsXmwPyzZHHLuFHahKc.png
new file mode 100644
index 0000000..3fc1589
Binary files /dev/null and b/storage/app/cccd/bCVgnJtuC1WdESEGCapCyBsXmwPyzZHHLuFHahKc.png differ
diff --git a/storage/app/public/cccd/c9LZL9tYpF7p4Ts4Ty6xVIPEo0ZIaQ9NJ8SSL7fK.png b/storage/app/cccd/c9LZL9tYpF7p4Ts4Ty6xVIPEo0ZIaQ9NJ8SSL7fK.png
similarity index 100%
rename from storage/app/public/cccd/c9LZL9tYpF7p4Ts4Ty6xVIPEo0ZIaQ9NJ8SSL7fK.png
rename to storage/app/cccd/c9LZL9tYpF7p4Ts4Ty6xVIPEo0ZIaQ9NJ8SSL7fK.png
diff --git a/tests/Feature/Api/Admin/Customer/ResetPasswordTest.php b/tests/Feature/Api/Admin/Customer/ResetPasswordTest.php
index a115f63..2499589 100644
--- a/tests/Feature/Api/Admin/Customer/ResetPasswordTest.php
+++ b/tests/Feature/Api/Admin/Customer/ResetPasswordTest.php
@@ -24,6 +24,9 @@ class ResetPasswordTest extends TestCase
{
parent::setUp();
+ // Email render từ template DB; template password_reset_admin chỉ có trong seeder.
+ $this->seed(\Database\Seeders\TemplateSeeder::class);
+
Role::firstOrCreate(['name' => 'Full Administrator', 'guard_name' => 'admin']);
$this->admin = User::factory()->create();
$this->admin->assignRole('Full Administrator');
diff --git a/tests/Feature/Api/Admin/PermissionGatingTest.php b/tests/Feature/Api/Admin/PermissionGatingTest.php
index 440e847..313f260 100644
--- a/tests/Feature/Api/Admin/PermissionGatingTest.php
+++ b/tests/Feature/Api/Admin/PermissionGatingTest.php
@@ -59,7 +59,6 @@ class PermissionGatingTest extends TestCase
'banks index' => ['GET', '/api/v1/administrator/banks', 'setting.manager'],
'domain-pricing index' => ['GET', '/api/v1/administrator/domain-pricing', 'setting.manager'],
'email-templates index' => ['GET', '/api/v1/administrator/email-templates', 'email-template.manager'],
- 'notification-templates' => ['GET', '/api/v1/administrator/notification-templates', 'email-template.manager'],
'tickets index' => ['GET', '/api/v1/administrator/tickets', 'support.view'],
'activity-logs index' => ['GET', '/api/v1/administrator/activity-logs', 'system-log.manager'],
'email-logs index' => ['GET', '/api/v1/administrator/email-logs', 'system-log.manager'],
diff --git a/tests/Feature/Api/Client/ClientAuthSecurityTest.php b/tests/Feature/Api/Client/ClientAuthSecurityTest.php
new file mode 100644
index 0000000..d8f5ca4
--- /dev/null
+++ b/tests/Feature/Api/Client/ClientAuthSecurityTest.php
@@ -0,0 +1,151 @@
+withHeaders(['x-access-token' => $this->buildClientAccessToken()]);
+ }
+
+ public function test_access_token_older_than_skew_window_is_rejected(): void
+ {
+ $this->withHeaders(['x-access-token' => $this->buildClientAccessToken(time() - 3600)])
+ ->getJson('/v1/banks')
+ ->assertStatus(403);
+ }
+
+ public function test_verify_email_code_is_invalidated_after_too_many_wrong_attempts(): void
+ {
+ $customer = Customer::factory()->create([
+ 'status' => 0,
+ 'email_verify_code' => '123456',
+ 'email_verify_code_expired_at' => now()->addMinutes(5),
+ ]);
+
+ for ($i = 0; $i < 5; $i++) {
+ $this->api()->postJson('/v1/client/verify-email', ['email' => $customer->email, 'code' => '000000'])
+ ->assertStatus(422);
+ }
+
+ // Lần sai thứ 5 huỷ mã: mã đúng cũng vô dụng, phải gửi lại.
+ $this->assertNull($customer->fresh()->email_verify_code);
+ $this->assertSame(0, (int) $customer->fresh()->status);
+
+ // throttle:client-auth khoá theo email: lần thứ 6 trong phút bị chặn, email khác vẫn qua.
+ $this->api()->postJson('/v1/client/verify-email', ['email' => $customer->email, 'code' => '123456'])
+ ->assertStatus(429);
+ $this->api()->postJson('/v1/client/verify-email', ['email' => 'other@example.com', 'code' => '123456'])
+ ->assertStatus(422);
+ }
+
+ public function test_forgot_password_sends_link_without_changing_password_and_reset_revokes_sessions(): void
+ {
+ Mail::fake();
+ $customer = Customer::factory()->create(['status' => 1]);
+ $oldHash = $customer->password;
+ RefreshToken::create(['customer_id' => $customer->id, 'token' => 'x', 'expires_at' => now()->addDay()]);
+
+ $this->api()->postJson('/v1/client/forgot-password', ['email' => strtoupper($customer->email)])->assertOk();
+
+ $this->assertSame($oldHash, $customer->fresh()->password);
+ Mail::assertQueued(PasswordResetLink::class, function (PasswordResetLink $mail) use ($customer) {
+ // Template seed bằng migration phải render được và chứa link reset, không chứa mật khẩu.
+ $html = $mail->render();
+
+ return $mail->hasTo($customer->email)
+ && str_contains($html, '/dat-lai-mat-khau?token=')
+ && str_contains($html, '60 phút');
+ });
+
+ $token = Password::broker('users')->createToken($customer);
+
+ $this->api()->postJson('/v1/client/reset-password', [
+ 'email' => $customer->email, 'token' => 'wrong', 'password' => 'NewPass123', 'password_confirmation' => 'NewPass123',
+ ])->assertStatus(422);
+
+ $this->api()->postJson('/v1/client/reset-password', [
+ 'email' => $customer->email, 'token' => $token, 'password' => 'NewPass123', 'password_confirmation' => 'NewPass123',
+ ])->assertOk();
+
+ $this->assertTrue(Hash::check('NewPass123', $customer->fresh()->password));
+ $this->assertSame(0, RefreshToken::where('customer_id', $customer->id)->count());
+ }
+
+ public function test_cccd_images_are_private_and_served_only_via_signed_url(): void
+ {
+ Storage::fake('local');
+ Storage::disk('local')->put('cccd/front.jpg', 'img');
+ $customer = Customer::factory()->create([
+ 'status' => 1,
+ 'identity_number' => '079123456789',
+ 'front_identity_number' => 'cccd/front.jpg',
+ ]);
+
+ $me = $this->actingAs($customer, 'client')->api()->getJson('/v1/client/me')->assertOk();
+ $data = $me->json('data.client.customer');
+
+ $this->assertSame('********6789', $data['cmnd']);
+ $this->assertStringStartsWith('/v1/client/cccd/' . $customer->id . '/front?expires=', $data['cmnd_before']);
+
+ $this->api()->get('/v1/client/cccd/' . $customer->id . '/front')->assertStatus(403);
+ $this->api()->get($data['cmnd_before'])->assertOk();
+ }
+
+ public function test_access_token_is_revoked_when_password_changes(): void
+ {
+ $customer = Customer::factory()->create(['status' => 1, 'password' => Hash::make('OldPass123')]);
+ $jwt = \Tymon\JWTAuth\Facades\JWTAuth::fromUser($customer);
+ $call = fn () => $this->api()->withHeader('Authorization', "Bearer {$jwt}")->getJson('/v1/client/me');
+
+ $call()->assertOk();
+
+ $this->api()->withHeader('Authorization', "Bearer {$jwt}")->putJson('/v1/client/change-password', [
+ 'current_password' => 'OldPass123', 'password' => 'NewPass123', 'password_confirmation' => 'NewPass123',
+ ])->assertOk();
+
+ $this->app['auth']->forgetGuards();
+ $call()->assertStatus(401);
+ }
+
+ public function test_throttle_uses_client_ip_from_encrypted_payload_not_next_server_ip(): void
+ {
+ // Cùng REMOTE_ADDR (Next server) nhưng IP khách khác nhau → không bị đếm chung.
+ foreach (['203.0.113.1', '203.0.113.2'] as $ip) {
+ for ($i = 0; $i < 60; $i++) {
+ $this->withHeaders(['x-access-token' => $this->buildClientAccessToken(null, $ip)])
+ ->getJson('/v1/banks')->assertOk();
+ }
+ }
+
+ $this->withHeaders(['x-access-token' => $this->buildClientAccessToken(null, '203.0.113.1')])
+ ->getJson('/v1/banks')->assertStatus(429);
+ }
+
+ public function test_legacy_cccd_path_is_not_exposed(): void
+ {
+ $customer = Customer::factory()->create(['status' => 1, 'front_identity_number' => '/uploads/2023/11/06/abc.jpg']);
+
+ $url = $this->actingAs($customer, 'client')->api()->getJson('/v1/client/me')
+ ->json('data.client.customer.cmnd_before');
+
+ $this->assertStringNotContainsString('uploads', $url);
+ $this->api()->get($url)->assertNotFound();
+ }
+}
diff --git a/tests/Feature/Api/DomainOrder/ClientDomainOrderTest.php b/tests/Feature/Api/DomainOrder/ClientDomainOrderTest.php
index 60ece48..00b5596 100644
--- a/tests/Feature/Api/DomainOrder/ClientDomainOrderTest.php
+++ b/tests/Feature/Api/DomainOrder/ClientDomainOrderTest.php
@@ -23,7 +23,14 @@ class ClientDomainOrderTest extends TestCase
{
parent::setUp();
- $this->customer = Customer::factory()->create(['total' => 1_000_000]);
+ $this->withHeaders(['x-access-token' => $this->buildClientAccessToken()]);
+
+ $this->customer = Customer::factory()->create([
+ 'total' => 1_000_000,
+ // DomainOrderService bắt buộc đã có ảnh CCCD 2 mặt
+ 'front_identity_number' => 'cccd/front.jpg',
+ 'back_identity_number' => 'cccd/back.jpg',
+ ]);
// Minimal domain product — product_type is set automatically from category
// but we force it directly since we have no category seeder in tests
diff --git a/tests/Feature/Api/Service/RenewServiceTest.php b/tests/Feature/Api/Service/RenewServiceTest.php
index 298b752..d4c6588 100644
--- a/tests/Feature/Api/Service/RenewServiceTest.php
+++ b/tests/Feature/Api/Service/RenewServiceTest.php
@@ -41,15 +41,6 @@ class RenewServiceTest extends TestCase
}
/** Encrypts the shared secret the same way ClientAccessTokenCheck expects, for test requests. */
- private function buildClientAccessToken(): string
- {
- $key = hex2bin((string) config('services.client_api.key'));
- $secret = (string) config('services.client_api.secret');
- $iv = random_bytes(16);
- $ciphertext = openssl_encrypt($secret, 'aes-256-cbc', $key, OPENSSL_RAW_DATA, $iv);
-
- return base64_encode($iv . $ciphertext);
- }
private function createServiceWithOldPrice(int $oldPrice): Service
{
diff --git a/tests/Feature/Api/Service/VmOwnershipTest.php b/tests/Feature/Api/Service/VmOwnershipTest.php
index 609958d..808189f 100644
--- a/tests/Feature/Api/Service/VmOwnershipTest.php
+++ b/tests/Feature/Api/Service/VmOwnershipTest.php
@@ -13,15 +13,6 @@ class VmOwnershipTest extends TestCase
{
use RefreshDatabase;
- private function buildClientAccessToken(): string
- {
- $key = hex2bin((string) config('services.client_api.key'));
- $secret = (string) config('services.client_api.secret');
- $iv = random_bytes(16);
- $ciphertext = openssl_encrypt($secret, 'aes-256-cbc', $key, OPENSSL_RAW_DATA, $iv);
-
- return base64_encode($iv . $ciphertext);
- }
public function test_customer_cannot_read_power_state_of_another_customers_vm_by_numeric_id(): void
{
diff --git a/tests/Feature/Console/InvoiceReminderTest.php b/tests/Feature/Console/InvoiceReminderTest.php
new file mode 100644
index 0000000..6f17725
--- /dev/null
+++ b/tests/Feature/Console/InvoiceReminderTest.php
@@ -0,0 +1,106 @@
+ [-7, -3, 0, 3]]);
+ }
+
+ public function test_no_reminder_is_sent_before_the_first_offset(): void
+ {
+ $invoice = $this->makeInvoice(now()->addDays(30));
+
+ $this->artisan('invoices:send-reminders')->assertExitCode(0);
+
+ $this->assertSame(0, (int) $invoice->fresh()->reminder_step);
+ }
+
+ public function test_reminder_step_advances_once_per_offset_reached(): void
+ {
+ // Hạn còn 5 ngày → đã qua mốc -7, chưa tới mốc -3.
+ $invoice = $this->makeInvoice(now()->addDays(5));
+
+ $this->artisan('invoices:send-reminders')->assertExitCode(0);
+ $this->assertSame(1, (int) $invoice->fresh()->reminder_step);
+
+ // Chạy lại trong ngày không được gửi trùng.
+ $this->artisan('invoices:send-reminders')->assertExitCode(0);
+ $this->assertSame(1, (int) $invoice->fresh()->reminder_step);
+ }
+
+ public function test_an_overdue_invoice_reaches_the_later_offsets(): void
+ {
+ $invoice = $this->makeInvoice(now()->subDays(4), InvoiceStatus::OVERDUE->value);
+
+ $this->artisan('invoices:send-reminders')->assertExitCode(0);
+
+ // Đã qua cả 4 mốc: -7, -3, 0, +3.
+ $this->assertSame(4, (int) $invoice->fresh()->reminder_step);
+ }
+
+ public function test_a_paid_invoice_is_never_reminded(): void
+ {
+ $invoice = $this->makeInvoice(now()->subDays(10), InvoiceStatus::PAID->value);
+
+ $this->artisan('invoices:send-reminders')->assertExitCode(0);
+
+ $this->assertSame(0, (int) $invoice->fresh()->reminder_step);
+ $this->assertFalse(app(InvoiceReminderService::class)->isRemindable($invoice->fresh()));
+ }
+
+ /** Nút nhắc thủ công của kế toán gửi được kể cả khi mốc đó đã gửi rồi. */
+ public function test_manual_reminder_sends_even_when_the_offset_was_already_used(): void
+ {
+ $invoice = $this->makeInvoice(now()->addDays(5));
+ $reminders = app(InvoiceReminderService::class);
+
+ $this->assertTrue($reminders->remind($invoice));
+ $this->assertFalse($reminders->remind($invoice->fresh()));
+ $this->assertTrue($reminders->remind($invoice->fresh(), force: true));
+ }
+
+ private function makeInvoice(\Carbon\Carbon $dueDate, int $status = InvoiceStatus::UNPAID->value): Invoice
+ {
+ $customer = Customer::factory()->create();
+ $order = Order::create([
+ 'order_num' => 'ORD-' . uniqid(),
+ 'customer_id' => $customer->id,
+ 'sub_total' => 100_000,
+ 'total' => 100_000,
+ 'status' => OrderStatus::PENDING->value,
+ ]);
+
+ return Invoice::create([
+ 'invoice_number' => 'INV-' . uniqid(),
+ 'customer_id' => $customer->id,
+ 'order_id' => $order->id,
+ 'invoice_type' => 1,
+ 'sub_total' => 100_000,
+ 'tax' => 0,
+ 'total' => 100_000,
+ 'status' => $status,
+ 'due_date' => $dueDate->toDateString(),
+ ]);
+ }
+}
diff --git a/tests/Feature/Console/PostWhmcsSyncSafetyTest.php b/tests/Feature/Console/PostWhmcsSyncSafetyTest.php
new file mode 100644
index 0000000..e604e8b
--- /dev/null
+++ b/tests/Feature/Console/PostWhmcsSyncSafetyTest.php
@@ -0,0 +1,182 @@
+ [-7, -3, 0, 3, 7]]);
+ }
+
+ /** Hoá đơn quá hạn nhiều năm KHÔNG được mở lại chuỗi nhắc nợ. */
+ public function test_reminder_chain_does_not_reopen_for_long_overdue_legacy_invoices(): void
+ {
+ $invoice = $this->makeInvoice(now()->subYears(2), InvoiceStatus::OVERDUE->value);
+
+ $this->artisan('invoices:send-reminders')->assertExitCode(0);
+
+ $this->assertSame(0, (int) $invoice->fresh()->reminder_step);
+ $this->assertSame(0, app(InvoiceReminderService::class)->dueStep($invoice->fresh()));
+ }
+
+ /** Hoá đơn vừa quá hạn trong cửa sổ nhắc thì vẫn phải được nhắc như thường. */
+ public function test_recently_overdue_invoice_is_still_reminded(): void
+ {
+ $invoice = $this->makeInvoice(now()->subDays(3), InvoiceStatus::OVERDUE->value);
+
+ $this->artisan('invoices:send-reminders')->assertExitCode(0);
+
+ $this->assertGreaterThan(0, (int) $invoice->fresh()->reminder_step);
+ }
+
+ /** Admin vẫn nhắc tay được hoá đơn cũ dù chuỗi tự động đã đóng. */
+ public function test_manual_reminder_still_works_on_an_old_invoice(): void
+ {
+ $invoice = $this->makeInvoice(now()->subYears(2), InvoiceStatus::OVERDUE->value);
+
+ $this->assertTrue(
+ app(InvoiceReminderService::class)->remind($invoice->load('customer'), force: true)
+ );
+ }
+
+ /** Đơn import từ WHMCS ở trạng thái pending không được khoá khách đặt đơn mới. */
+ public function test_legacy_pending_order_does_not_block_new_orders(): void
+ {
+ $customer = Customer::factory()->create();
+
+ Order::create([
+ 'legacy_id' => 90001,
+ 'order_num' => 'WHMCS-' . uniqid(),
+ 'customer_id' => $customer->id,
+ 'sub_total' => 500_000,
+ 'total' => 500_000,
+ 'status' => OrderStatus::PENDING->value,
+ ]);
+
+ $this->assertFalse(app(OrderRepository::class)->hasPendingOrder($customer->id));
+ }
+
+ /** Đơn pending tạo trên portal thì vẫn phải chặn như cũ. */
+ public function test_local_pending_order_still_blocks_new_orders(): void
+ {
+ $customer = Customer::factory()->create();
+
+ Order::create([
+ 'order_num' => 'ORD-' . uniqid(),
+ 'customer_id' => $customer->id,
+ 'sub_total' => 500_000,
+ 'total' => 500_000,
+ 'status' => OrderStatus::PENDING->value,
+ ]);
+
+ $this->assertTrue(app(OrderRepository::class)->hasPendingOrder($customer->id));
+ }
+
+ /**
+ * Dịch vụ import có invoice_id NULL dù hoá đơn gia hạn của nó đã được đồng bộ —
+ * không được xuất thêm hoá đơn trùng kỳ.
+ */
+ public function test_expiry_does_not_duplicate_an_invoice_already_synced_on_the_order(): void
+ {
+ $customer = Customer::factory()->create();
+ $product = Product::factory()->create(['product_type' => 'hosting', 'is_active' => true]);
+
+ $order = Order::create([
+ 'legacy_id' => 90002,
+ 'order_num' => 'WHMCS-' . uniqid(),
+ 'customer_id' => $customer->id,
+ 'sub_total' => 90_000,
+ 'total' => 90_000,
+ 'status' => OrderStatus::ACTIVE->value,
+ ]);
+
+ $orderItem = OrderItem::create([
+ 'order_id' => $order->id,
+ 'product_id' => $product->id,
+ 'billing_cycle' => 'monthly',
+ 'months' => 1,
+ 'quantity' => 1,
+ 'price' => 90_000,
+ ]);
+
+ // Hoá đơn gia hạn đã đồng bộ từ WHMCS, còn chưa thanh toán.
+ Invoice::create([
+ 'legacy_id' => 90003,
+ 'invoice_number' => 'WHMCS-INV-' . uniqid(),
+ 'customer_id' => $customer->id,
+ 'order_id' => $order->id,
+ 'invoice_type' => 2,
+ 'sub_total' => 90_000,
+ 'tax' => 0,
+ 'total' => 90_000,
+ 'status' => InvoiceStatus::UNPAID->value,
+ 'due_date' => now()->addDays(3)->toDateString(),
+ ]);
+
+ // Service import: invoice_id để trống đúng như ServiceWriter ghi.
+ $service = Service::create([
+ 'customer_id' => $customer->id,
+ 'product_id' => $product->id,
+ 'order_item_id' => $orderItem->id,
+ 'status' => 'active',
+ 'server_name' => 'legacy-server',
+ 'expires_at' => now()->addDays(3),
+ ]);
+ $this->assertNull($service->invoice_id);
+
+ $this->artisan('service:expiry')->assertExitCode(0);
+
+ // Vẫn đúng 1 hoá đơn — và service đã được trỏ về hoá đơn đang mở.
+ $this->assertSame(1, Invoice::where('order_id', $order->id)->count());
+ $this->assertNotNull($service->fresh()->invoice_id);
+ }
+
+ private function makeInvoice(\Carbon\Carbon $dueDate, int $status): Invoice
+ {
+ $customer = Customer::factory()->create();
+ $order = Order::create([
+ 'order_num' => 'ORD-' . uniqid(),
+ 'customer_id' => $customer->id,
+ 'sub_total' => 100_000,
+ 'total' => 100_000,
+ 'status' => OrderStatus::PENDING->value,
+ ]);
+
+ return Invoice::create([
+ 'invoice_number' => 'INV-' . uniqid(),
+ 'customer_id' => $customer->id,
+ 'order_id' => $order->id,
+ 'invoice_type' => 1,
+ 'sub_total' => 100_000,
+ 'tax' => 0,
+ 'total' => 100_000,
+ 'status' => $status,
+ 'due_date' => $dueDate->toDateString(),
+ ]);
+ }
+}
diff --git a/tests/Feature/Console/ServiceExpiryTest.php b/tests/Feature/Console/ServiceExpiryTest.php
index f41b678..7647060 100644
--- a/tests/Feature/Console/ServiceExpiryTest.php
+++ b/tests/Feature/Console/ServiceExpiryTest.php
@@ -127,4 +127,50 @@ class ServiceExpiryTest extends TestCase
// Vẫn đúng là OVERDUE — service:expiry không được đụng vào status của invoice.
$this->assertSame(InvoiceStatus::OVERDUE->value, (int) $invoice->fresh()->status);
}
+
+ /**
+ * Hoá đơn gia hạn dùng lại order_id gốc. Khi nó quá hạn, cron KHÔNG được kéo đơn đang chạy
+ * về PENDING — vừa hiển thị sai, vừa làm hasPendingOrder() khoá khách đặt đơn mới.
+ */
+ public function test_overdue_renewal_invoice_does_not_reset_an_active_order(): void
+ {
+ $service = $this->createExpiringService(90_000);
+ $order = $service->orderItem->order;
+ $this->assertSame(OrderStatus::ACTIVE->value, (int) $order->status);
+
+ $this->artisan('service:expiry')->assertExitCode(0);
+ Invoice::whereKey($service->refresh()->invoice_id)->update(['due_date' => now()->subDay()->toDateString()]);
+
+ $this->artisan('invoices:check-overdue')->assertExitCode(0);
+
+ $this->assertSame(OrderStatus::ACTIVE->value, (int) $order->fresh()->status);
+ }
+
+ /** Giá gia hạn phải theo bảng giá hiện hành, không đóng băng theo giá lúc mua. */
+ public function test_renewal_invoice_uses_the_current_price_list(): void
+ {
+ $service = $this->createExpiringService(90_000);
+
+ $service->orderItem->product->pricings()->create([
+ 'billing_cycle' => 'monthly',
+ 'price' => 120_000,
+ 'is_active' => 1,
+ ]);
+
+ $this->artisan('service:expiry')->assertExitCode(0);
+
+ $invoice = Invoice::find($service->refresh()->invoice_id);
+ $this->assertSame(120_000, (int) $invoice->total);
+ }
+
+ /** Không có giá hiện hành (sản phẩm ngừng bán) thì lùi về giá cũ, vẫn xuất được hoá đơn. */
+ public function test_renewal_falls_back_to_the_original_price_when_no_current_price_exists(): void
+ {
+ $service = $this->createExpiringService(90_000);
+
+ $this->artisan('service:expiry')->assertExitCode(0);
+
+ $invoice = Invoice::find($service->refresh()->invoice_id);
+ $this->assertSame(90_000, (int) $invoice->total);
+ }
}
diff --git a/tests/Feature/ExampleTest.php b/tests/Feature/ExampleTest.php
deleted file mode 100644
index 78ccc21..0000000
--- a/tests/Feature/ExampleTest.php
+++ /dev/null
@@ -1,21 +0,0 @@
-get('/');
-
- $response->assertStatus(200);
- }
-}
diff --git a/tests/Feature/Payment/AdminTransactionApproveTest.php b/tests/Feature/Payment/AdminTransactionApproveTest.php
index 2db5aa0..4799a16 100644
--- a/tests/Feature/Payment/AdminTransactionApproveTest.php
+++ b/tests/Feature/Payment/AdminTransactionApproveTest.php
@@ -9,6 +9,7 @@ use App\Http\Controllers\Api\Admin\Transaction\AdminTransactionController;
use App\Models\Customer;
use App\Models\Transaction;
use Illuminate\Foundation\Testing\RefreshDatabase;
+use Illuminate\Http\Request;
use Illuminate\Support\Facades\Mail;
use Tests\TestCase;
@@ -33,8 +34,8 @@ class AdminTransactionApproveTest extends TestCase
$controller = app(AdminTransactionController::class);
- $first = $controller->approve($transaction->uuid);
- $second = $controller->approve($transaction->uuid);
+ $first = $controller->approve(new Request(), $transaction->uuid);
+ $second = $controller->approve(new Request(), $transaction->uuid);
$this->assertSame(200, $first->getStatusCode());
// Second call finds the transaction no longer PENDING → treated as not-approvable.
@@ -42,4 +43,61 @@ class AdminTransactionApproveTest extends TestCase
$this->assertSame(100_000, (int) $customer->fresh()->total);
}
+
+ /**
+ * Khách khai 5tr nhưng sao kê chỉ về 4tr: số dư phải cộng theo số THỰC NHẬN, phần chênh
+ * được ghi lại để kế toán đối chiếu. Trước đây luôn cộng theo số khách khai.
+ */
+ public function test_approving_credits_the_received_amount_not_the_declared_one(): void
+ {
+ Mail::fake();
+
+ $customer = Customer::factory()->create(['total' => 0, 'credit' => 0, 'total_pending' => 5_000_000]);
+ $transaction = Transaction::create([
+ 'uuid' => (string) \Illuminate\Support\Str::uuid(),
+ 'public_code' => 'DEP-TEST-0002',
+ 'trans' => 'DEP-TEST-0002',
+ 'amount' => 5_000_000,
+ 'customer_id' => $customer->id,
+ 'type' => 'manual',
+ 'status' => TransactionStatus::PENDING->value,
+ ]);
+
+ $request = Request::create('/', 'POST', ['received_amount' => 4_000_000]);
+
+ $response = app(AdminTransactionController::class)->approve($request, $transaction->uuid);
+
+ $this->assertSame(200, $response->getStatusCode());
+ $this->assertSame(4_000_000, (int) $customer->fresh()->total);
+ $this->assertSame(40_000, (int) $customer->fresh()->credit);
+ $this->assertSame(0, (int) $customer->fresh()->total_pending);
+
+ $transaction->refresh();
+ $this->assertSame(5_000_000, (int) $transaction->amount);
+ $this->assertSame(4_000_000, (int) $transaction->received_amount);
+ $this->assertStringContainsString('Lệch ' . number_format(1_000_000), (string) $transaction->note);
+ }
+
+ /** Bỏ trống received_amount = tin số khách khai, giữ nguyên hành vi cũ. */
+ public function test_approving_without_received_amount_credits_the_declared_amount(): void
+ {
+ Mail::fake();
+
+ $customer = Customer::factory()->create(['total' => 0, 'credit' => 0, 'total_pending' => 300_000]);
+ $transaction = Transaction::create([
+ 'uuid' => (string) \Illuminate\Support\Str::uuid(),
+ 'public_code' => 'DEP-TEST-0003',
+ 'trans' => 'DEP-TEST-0003',
+ 'amount' => 300_000,
+ 'customer_id' => $customer->id,
+ 'type' => 'manual',
+ 'status' => TransactionStatus::PENDING->value,
+ ]);
+
+ app(AdminTransactionController::class)->approve(new Request(), $transaction->uuid);
+
+ $this->assertSame(300_000, (int) $customer->fresh()->total);
+ $this->assertSame(300_000, (int) $transaction->fresh()->received_amount);
+ $this->assertStringNotContainsString('Lệch', (string) $transaction->fresh()->note);
+ }
}
diff --git a/tests/Feature/Payment/InvoicePaymentServiceTest.php b/tests/Feature/Payment/InvoicePaymentServiceTest.php
index 9a08d94..bea42ac 100644
--- a/tests/Feature/Payment/InvoicePaymentServiceTest.php
+++ b/tests/Feature/Payment/InvoicePaymentServiceTest.php
@@ -81,4 +81,109 @@ class InvoicePaymentServiceTest extends TestCase
$this->assertNull($invoice->fresh()->paid_at);
}
}
+
+ /** Guard mới: admin không được bật hoá đơn đã huỷ sang đã thanh toán. */
+ public function test_admin_cannot_mark_a_cancelled_invoice_as_paid(): void
+ {
+ $customer = Customer::factory()->create(['total' => 200_000]);
+ $invoice = $this->makeInvoice($customer, 100_000, InvoiceStatus::CANCELLED->value);
+
+ $this->expectException(\InvalidArgumentException::class);
+ try {
+ app(InvoicePaymentService::class)->markPaidByAdmin($invoice, null);
+ } finally {
+ $this->assertSame(InvoiceStatus::CANCELLED->value, (int) $invoice->fresh()->status);
+ $this->assertNull($invoice->fresh()->paid_at);
+ }
+ }
+
+ /** Trả thiếu: cộng dồn amount_paid, hoá đơn vẫn chưa thanh toán, dịch vụ chưa kích hoạt. */
+ public function test_partial_payment_records_amount_paid_and_keeps_invoice_unpaid(): void
+ {
+ $customer = Customer::factory()->create(['total' => 200_000]);
+ $invoice = $this->makeInvoice($customer, 100_000);
+
+ $service = app(InvoicePaymentService::class);
+ $invoice = $service->payWithBalance($invoice, $customer, 'balance', 40_000);
+
+ $this->assertSame(40_000, (int) $invoice->amount_paid);
+ $this->assertSame(InvoiceStatus::UNPAID->value, (int) $invoice->status);
+ $this->assertNull($invoice->paid_at);
+ $this->assertSame(60_000, InvoicePaymentService::outstanding($invoice));
+ $this->assertSame(160_000, (int) $customer->fresh()->total);
+ $this->assertSame(OrderStatus::PENDING->value, (int) $invoice->order->fresh()->status);
+ }
+
+ /** Trả nốt phần còn thiếu thì hoá đơn mới chuyển PAID và đơn hàng mới được kích hoạt. */
+ public function test_paying_the_remainder_settles_the_invoice(): void
+ {
+ $customer = Customer::factory()->create(['total' => 200_000]);
+ $invoice = $this->makeInvoice($customer, 100_000);
+
+ $service = app(InvoicePaymentService::class);
+ $service->payWithBalance($invoice, $customer, 'balance', 40_000);
+ $invoice = $service->payWithBalance($invoice->fresh(), $customer->fresh(), 'balance');
+
+ $this->assertSame(100_000, (int) $invoice->amount_paid);
+ $this->assertSame(InvoiceStatus::PAID->value, (int) $invoice->status);
+ $this->assertNotNull($invoice->paid_at);
+ $this->assertSame(100_000, (int) $customer->fresh()->total);
+ $this->assertSame(OrderStatus::ACTIVE->value, (int) $invoice->order->fresh()->status);
+ // Mỗi lần trả là một dòng sổ riêng, không gộp cũng không ghi đè.
+ $this->assertSame(2, Transaction::where('customer_id', $customer->id)->where('type', 'invoice')->count());
+ }
+
+ /** Trả quá số còn thiếu thì chỉ trừ đúng phần còn thiếu, không thu dư. */
+ public function test_overpaying_only_charges_the_outstanding_amount(): void
+ {
+ $customer = Customer::factory()->create(['total' => 500_000]);
+ $invoice = $this->makeInvoice($customer, 100_000);
+
+ $invoice = app(InvoicePaymentService::class)->payWithBalance($invoice, $customer, 'balance', 300_000);
+
+ $this->assertSame(100_000, (int) $invoice->amount_paid);
+ $this->assertSame(400_000, (int) $customer->fresh()->total);
+ }
+
+ /** Hoàn tiền: cộng lại số dư, ghi Transaction âm, hoá đơn sang REFUNDED. */
+ public function test_refund_returns_balance_and_records_a_negative_transaction(): void
+ {
+ $customer = Customer::factory()->create(['total' => 200_000]);
+ $invoice = $this->makeInvoice($customer, 100_000);
+
+ $service = app(InvoicePaymentService::class);
+ $invoice = $service->payWithBalance($invoice, $customer, 'balance');
+ $this->assertSame(100_000, (int) $customer->fresh()->total);
+
+ $invoice = $service->refund($invoice, null, null, 'Khách huỷ dịch vụ');
+
+ $this->assertSame(InvoiceStatus::REFUNDED->value, (int) $invoice->status);
+ $this->assertSame(200_000, (int) $customer->fresh()->total);
+ $this->assertSame(
+ -100_000,
+ (int) Transaction::where('customer_id', $customer->id)->where('type', 'refund')->value('amount'),
+ );
+ }
+
+ private function makeInvoice(Customer $customer, int $total, int $status = InvoiceStatus::UNPAID->value): Invoice
+ {
+ $order = Order::create([
+ 'order_num' => 'ORD-' . uniqid(),
+ 'customer_id' => $customer->id,
+ 'sub_total' => $total,
+ 'total' => $total,
+ 'status' => OrderStatus::PENDING->value,
+ ]);
+
+ return Invoice::create([
+ 'invoice_number' => 'INV-' . uniqid(),
+ 'customer_id' => $customer->id,
+ 'order_id' => $order->id,
+ 'invoice_type' => 1,
+ 'sub_total' => $total,
+ 'tax' => 0,
+ 'total' => $total,
+ 'status' => $status,
+ ]);
+ }
}
diff --git a/tests/TestCase.php b/tests/TestCase.php
index 2932d4a..afd00bd 100644
--- a/tests/TestCase.php
+++ b/tests/TestCase.php
@@ -7,4 +7,16 @@ use Illuminate\Foundation\Testing\TestCase as BaseTestCase;
abstract class TestCase extends BaseTestCase
{
use CreatesApplication;
+
+ /**
+ * Header `x-access-token` như portal-client gửi (xem ClientAccessTokenCheck).
+ */
+ protected function buildClientAccessToken(?int $timestamp = null, ?string $ip = null): string
+ {
+ $key = hex2bin((string) config('services.client_api.key'));
+ $payload = ['s' => (string) config('services.client_api.secret'), 't' => $timestamp ?? time()] + ($ip ? ['ip' => $ip] : []);
+ $iv = random_bytes(16);
+
+ return base64_encode($iv . openssl_encrypt(json_encode($payload), 'aes-256-cbc', $key, OPENSSL_RAW_DATA, $iv));
+ }
}
diff --git a/tests/Unit/VMDataServiceTest.php b/tests/Unit/VMDataServiceTest.php
deleted file mode 100644
index ec3242c..0000000
--- a/tests/Unit/VMDataServiceTest.php
+++ /dev/null
@@ -1,320 +0,0 @@
-vmDataService = new VMDataService();
- }
-
- /** @test */
- public function it_can_save_vm_data_successfully()
- {
- // Arrange
- $customer = Customer::factory()->create();
- $order = Order::factory()->create(['customer_id' => $customer->id]);
- $host = ModuleHost::factory()->create();
- $product = Product::factory()->create(['module_host_id' => $host->id]);
- $orderItem = OrderItem::factory()->create([
- 'order_id' => $order->id,
- 'product_id' => $product->id
- ]);
-
- $vmwareId = 'vm-12345';
- $ipAddresses = [
- ['ip' => '192.168.1.100', 'type' => 'IPV4', 'state' => 'PREFERRED']
- ];
-
- // Act
- $result = $this->vmDataService->saveVMData($orderItem, $vmwareId, $ipAddresses);
-
- // Assert
- $this->assertTrue($result['success']);
- $this->assertInstanceOf(ModuleVmware::class, $result['vm']);
- $this->assertInstanceOf(ModuleIpAddress::class, $result['ip_address']);
- $this->assertEquals($vmwareId, $result['vm']->vmware_id);
- $this->assertEquals('192.168.1.100', $result['ip_address']->ip);
- $this->assertEquals($customer->id, $result['vm']->customer_id);
- $this->assertEquals($order->id, $result['vm']->order_id);
- }
-
- /** @test */
- public function it_can_update_existing_vm_data()
- {
- // Arrange
- $customer = Customer::factory()->create();
- $order = Order::factory()->create(['customer_id' => $customer->id]);
- $host = ModuleHost::factory()->create();
- $product = Product::factory()->create(['module_host_id' => $host->id]);
- $orderItem = OrderItem::factory()->create([
- 'order_id' => $order->id,
- 'product_id' => $product->id
- ]);
-
- $vmwareId = 'vm-12345';
- $ipAddresses = [
- ['ip' => '192.168.1.100', 'type' => 'IPV4', 'state' => 'PREFERRED']
- ];
-
- // Create existing VM
- $existingVM = ModuleVmware::factory()->create([
- 'vmware_id' => $vmwareId,
- 'name' => $product->name
- ]);
-
- // Act
- $result = $this->vmDataService->saveVMData($orderItem, $vmwareId, $ipAddresses);
-
- // Assert
- $this->assertTrue($result['success']);
- $this->assertEquals($existingVM->id, $result['vm']->id);
- $this->assertEquals($order->id, $result['vm']->order_id);
- }
-
- /** @test */
- public function it_can_update_vm_status()
- {
- // Arrange
- $vm = ModuleVmware::factory()->create(['vmware_id' => 'vm-12345']);
-
- // Act
- $result = $this->vmDataService->updateVMStatus('vm-12345', 2);
-
- // Assert
- $this->assertTrue($result);
- $this->assertEquals(2, $vm->fresh()->status);
- }
-
- /** @test */
- public function it_can_update_vm_ip_address()
- {
- // Arrange
- $ipAddress = ModuleIpAddress::factory()->create(['ip' => '192.168.1.100']);
- $vm = ModuleVmware::factory()->create([
- 'vmware_id' => 'vm-12345',
- 'module_ip_address_id' => $ipAddress->id
- ]);
-
- // Act
- $result = $this->vmDataService->updateVMIPAddress('vm-12345', '192.168.1.200');
-
- // Assert
- $this->assertTrue($result);
- $this->assertEquals('192.168.1.200', $ipAddress->fresh()->ip);
- $this->assertEquals(1, $ipAddress->fresh()->status);
- }
-
- /** @test */
- public function it_returns_false_when_vm_not_found_for_status_update()
- {
- // Act
- $result = $this->vmDataService->updateVMStatus('non-existent-vm', 1);
-
- // Assert
- $this->assertFalse($result);
- }
-
- /** @test */
- public function it_returns_false_when_vm_not_found_for_ip_update()
- {
- // Act
- $result = $this->vmDataService->updateVMIPAddress('non-existent-vm', '192.168.1.100');
-
- // Assert
- $this->assertFalse($result);
- }
-
- /** @test */
- public function it_handles_payment_fields_with_default_values()
- {
- // Arrange
- $customer = Customer::factory()->create();
- $order = Order::factory()->create([
- 'customer_id' => $customer->id,
- 'first_payment' => null, // Test null value
- 'next_payment' => '', // Test empty string
- 'next_invoice' => 'invalid', // Test invalid value
- ]);
- $host = ModuleHost::factory()->create();
- $product = Product::factory()->create(['module_host_id' => $host->id]);
- $orderItem = OrderItem::factory()->create([
- 'order_id' => $order->id,
- 'product_id' => $product->id
- ]);
-
- $vmwareId = 'vm-12345';
- $ipAddresses = [
- ['ip' => '192.168.1.100', 'type' => 'IPV4', 'state' => 'PREFERRED']
- ];
-
- // Act
- $result = $this->vmDataService->saveVMData($orderItem, $vmwareId, $ipAddresses);
-
- // Assert
- $this->assertTrue($result['success']);
- $this->assertEquals(0, $result['vm']->first_payment); // Should default to 0
- $this->assertEquals(0, $result['vm']->next_payment); // Should default to 0
- $this->assertEquals(0, $result['vm']->next_invoice); // Should default to 0
- $this->assertEquals('0.0.0.0', $result['ip_address']->gateway); // Should default to 0.0.0.0
- }
-
- /** @test */
- public function it_handles_valid_payment_fields()
- {
- // Arrange
- $customer = Customer::factory()->create();
- $order = Order::factory()->create([
- 'customer_id' => $customer->id,
- 'first_payment' => 100000,
- 'next_payment' => 200000,
- 'next_invoice' => 300000,
- ]);
- $host = ModuleHost::factory()->create();
- $product = Product::factory()->create(['module_host_id' => $host->id]);
- $orderItem = OrderItem::factory()->create([
- 'order_id' => $order->id,
- 'product_id' => $product->id
- ]);
-
- $vmwareId = 'vm-12345';
- $ipAddresses = [
- ['ip' => '192.168.1.100', 'type' => 'IPV4', 'state' => 'PREFERRED']
- ];
-
- // Act
- $result = $this->vmDataService->saveVMData($orderItem, $vmwareId, $ipAddresses);
-
- // Assert
- $this->assertTrue($result['success']);
- $this->assertEquals(100000, $result['vm']->first_payment);
- $this->assertEquals(200000, $result['vm']->next_payment);
- $this->assertEquals(300000, $result['vm']->next_invoice);
- }
-
- /**
- * Test saving VM data with server_name in order item (should create new records)
- */
- public function test_save_vm_data_with_server_name(): void
- {
- // Arrange
- $orderItem = OrderItem::factory()->create([
- 'server_name' => 'linuxtestpb.com'
- ]);
- $vmwareId = 'vm-12345';
- $ipAddresses = [
- ['ip' => '192.168.1.100', 'type' => 'ipv4']
- ];
-
- // Mock the product and related models
- $product = Mockery::mock();
- $product->shouldReceive('getAttribute')->andReturnSelf();
- $product->name = 'Test Product';
- $product->host = Mockery::mock(['id' => 1]);
- $product->ipAddress = null;
-
- $orderItem->shouldReceive('getProductAttribute')->andReturn($product);
- $orderItem->order = Mockery::mock([
- 'id' => 1,
- 'customer' => Mockery::mock(['id' => 1]),
- 'first_payment' => 100,
- 'next_payment' => 200,
- 'next_invoice' => 300,
- 'next_due_date' => now()->addMonth()
- ]);
-
- // Mock ModuleIpAddress - should only create
- $ipAddress = Mockery::mock(ModuleIpAddress::class);
- $ipAddress->id = 1;
-
- ModuleIpAddress::shouldReceive('create')
- ->andReturn($ipAddress);
-
- // Mock ModuleVmware - should only create
- $vm = Mockery::mock(ModuleVmware::class);
- $vm->id = 1;
- $vm->vmware_id = $vmwareId;
-
- ModuleVmware::shouldReceive('create')
- ->andReturn($vm);
-
- // Act
- $result = $this->vmDataService->saveVMData($orderItem, $vmwareId, $ipAddresses);
-
- // Assert
- $this->assertTrue($result['success']);
- $this->assertEquals($vm, $result['vm']);
- $this->assertEquals($ipAddress, $result['ip_address']);
- $this->assertArrayNotHasKey('skipped', $result);
- }
-
- /**
- * Test saving VM data without server_name in order item (should skip saving)
- */
- public function test_save_vm_data_without_server_name(): void
- {
- // Arrange
- $orderItem = OrderItem::factory()->create([
- 'server_name' => null
- ]);
- $vmwareId = 'vm-12345';
- $ipAddresses = [
- ['ip' => '192.168.1.100', 'type' => 'ipv4']
- ];
-
- // Act
- $result = $this->vmDataService->saveVMData($orderItem, $vmwareId, $ipAddresses);
-
- // Assert
- $this->assertTrue($result['success']);
- $this->assertTrue($result['skipped']);
- $this->assertEquals('Bỏ qua lưu dữ liệu VM vì không có server_name trong order item', $result['message']);
- $this->assertArrayNotHasKey('vm', $result);
- $this->assertArrayNotHasKey('ip_address', $result);
- }
-
- /**
- * Test saving VM data with empty server_name in order item (should skip saving)
- */
- public function test_save_vm_data_with_empty_server_name(): void
- {
- // Arrange
- $orderItem = OrderItem::factory()->create([
- 'server_name' => ''
- ]);
- $vmwareId = 'vm-12345';
- $ipAddresses = [
- ['ip' => '192.168.1.100', 'type' => 'ipv4']
- ];
-
- // Act
- $result = $this->vmDataService->saveVMData($orderItem, $vmwareId, $ipAddresses);
-
- // Assert
- $this->assertTrue($result['success']);
- $this->assertTrue($result['skipped']);
- $this->assertEquals('Bỏ qua lưu dữ liệu VM vì không có server_name trong order item', $result['message']);
- $this->assertArrayNotHasKey('vm', $result);
- $this->assertArrayNotHasKey('ip_address', $result);
- }
-}
\ No newline at end of file
diff --git a/tests/check-email-template-placeholders.php b/tests/check-email-template-placeholders.php
new file mode 100644
index 0000000..88fb2a9
--- /dev/null
+++ b/tests/check-email-template-placeholders.php
@@ -0,0 +1,111 @@
+make(Illuminate\Contracts\Console\Kernel::class)->bootstrap();
+
+$failures = 0;
+
+// ── 1 + 2. Từng template phải render được ────────────────────────────────────
+$templates = App\Models\Template::where('status', 1)->get();
+echo "Soát {$templates->count()} template đang bật.\n\n";
+
+foreach ($templates as $template) {
+ $name = $template->scenario ?: ($template->key ?: "#{$template->id}");
+ $content = (string) $template->subject . ' ' . (string) $template->message;
+
+ preg_match_all('/\{\{\s*([a-zA-Z_][a-zA-Z0-9_]*)\s*\}\}/', $content, $matches);
+ $used = array_unique($matches[1]);
+ $declared = array_keys($template->variables());
+ $undeclared = array_values(array_diff($used, $declared));
+
+ // Layout dùng {{$content}} làm chỗ cắm nội dung — đó là quy ước riêng, không phải biến.
+ $bladeish = $template->scenario === 'layout'
+ ? []
+ : array_values(array_filter(
+ preg_match_all('/\{\{\s*\$[a-zA-Z_]+\s*\}\}/', $content, $m2) ? $m2[0] : []
+ ));
+
+ if ($undeclared !== []) {
+ echo " ✘ {$name}: biến chưa khai báo → " . implode(', ', $undeclared) . "\n";
+ $failures++;
+ continue;
+ }
+
+ if ($bladeish !== []) {
+ echo " ✘ {$name}: còn placeholder blade → " . implode(', ', array_unique($bladeish)) . "\n";
+ $failures++;
+ continue;
+ }
+
+ echo " ✔ {$name}\n";
+}
+
+// ── 3. Mailable nào trỏ tới scenario chưa có row ─────────────────────────────
+echo "\nSoát scenario mà Mailable đang dùng.\n\n";
+
+$mailFiles = new RecursiveIteratorIterator(new RecursiveDirectoryIterator(__DIR__ . '/../app/Mail'));
+$referenced = [];
+
+foreach ($mailFiles as $file) {
+ if ($file->isDir() || $file->getExtension() !== 'php') {
+ continue;
+ }
+ if (preg_match_all("/renderFromTemplate\(\s*'([a-z0-9_]+)'/", file_get_contents($file->getPathname()), $m)) {
+ foreach ($m[1] as $scenario) {
+ $referenced[$scenario] = basename($file->getPathname());
+ }
+ }
+}
+
+$existing = App\Models\Template::where('status', 1)
+ ->get(['scenario', 'key'])
+ ->flatMap(fn ($t) => array_filter([$t->scenario, $t->key]))
+ ->unique()
+ ->all();
+
+foreach ($referenced as $scenario => $mailable) {
+ if (in_array($scenario, $existing, true)) {
+ echo " ✔ {$scenario} ({$mailable})\n";
+ } else {
+ echo " ✘ {$scenario} ({$mailable}): KHÔNG có template — gửi sẽ ném ModelNotFoundException\n";
+ $failures++;
+ }
+}
+
+// ── 4. Không còn Mailable nào render qua Blade ───────────────────────────────
+echo "\nSoát Blade trong app/Mail.\n\n";
+
+$bladeMailables = [];
+foreach (new RecursiveIteratorIterator(new RecursiveDirectoryIterator(__DIR__ . '/../app/Mail')) as $file) {
+ if ($file->isDir() || $file->getExtension() !== 'php') {
+ continue;
+ }
+ if (preg_match('/\bview:\s*\'|\bmarkdown:\s*\'|->view\(|->markdown\(/', file_get_contents($file->getPathname()))) {
+ $bladeMailables[] = basename($file->getPathname());
+ }
+}
+
+if ($bladeMailables === []) {
+ echo " ✔ Không Mailable nào dùng Blade.\n";
+} else {
+ echo " ✘ Còn dùng Blade: " . implode(', ', $bladeMailables) . "\n";
+ $failures += count($bladeMailables);
+}
+
+echo "\n" . ($failures === 0 ? "TẤT CẢ ĐẠT.\n" : "CÓ {$failures} LỖI.\n");
+
+assert($failures === 0, "Có {$failures} template/mailable không đạt");
+
+exit($failures === 0 ? 0 : 1);