This commit is contained in:
baodev0
2026-09-23 15:17:18 +07:00
parent cd38c5d6d0
commit 82a71244ca
107 changed files with 3332 additions and 3306 deletions
@@ -1,127 +0,0 @@
<?php
namespace App\Http\Controllers\Api\Admin;
use App\Http\Controllers\Controller;
use App\Models\EmailNotificationTemplate;
use App\Services\EmailNotificationTemplateRenderer;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
class EmailNotificationTemplateController extends Controller
{
public function __construct(
private readonly EmailNotificationTemplateRenderer $renderer,
) {}
public function index(): JsonResponse
{
$templates = EmailNotificationTemplate::orderByDesc('is_active')
->orderBy('name')
->get();
return response()->json(['error' => false, 'data' => $templates]);
}
public function show(int $id): JsonResponse
{
$tpl = EmailNotificationTemplate::findOrFail($id);
$tpl['block_field_definitions'] = EmailNotificationTemplate::blockFieldDefinitions();
return response()->json(['error' => false, 'data' => $tpl]);
}
public function store(Request $request): JsonResponse
{
$validated = $request->validate([
'name' => 'required|string|max:255',
'type' => 'required|string|in:invoice_notification,vm_credentials',
'subject' => 'required|string|max:500',
'header_color' => 'nullable|string|max:20',
'header_title' => 'nullable|string|max:255',
'blocks' => 'nullable|array',
'is_active' => 'boolean',
]);
// Use default blocks if none provided
if (empty($validated['blocks'])) {
$validated['blocks'] = EmailNotificationTemplate::defaultBlocks($validated['type']);
}
// If activating this template, deactivate others of the same type
if ($validated['is_active'] ?? true) {
EmailNotificationTemplate::where('type', $validated['type'])->update(['is_active' => false]);
}
$tpl = EmailNotificationTemplate::create($validated);
return response()->json(['error' => false, 'data' => $tpl], 201);
}
public function update(Request $request, int $id): JsonResponse
{
$tpl = EmailNotificationTemplate::findOrFail($id);
$validated = $request->validate([
'name' => 'sometimes|string|max:255',
'subject' => 'sometimes|string|max:500',
'header_color' => 'nullable|string|max:20',
'header_title' => 'nullable|string|max:255',
'blocks' => 'nullable|array',
'is_active' => 'boolean',
]);
// If activating this template, deactivate others of the same type
if (!empty($validated['is_active'])) {
EmailNotificationTemplate::where('type', $tpl->type)
->where('id', '!=', $id)
->update(['is_active' => false]);
}
$tpl->update($validated);
return response()->json(['error' => false, 'data' => $tpl->fresh()]);
}
public function destroy(int $id): JsonResponse
{
EmailNotificationTemplate::findOrFail($id)->delete();
return response()->json(['error' => false, 'message' => 'Đã xoá template.']);
}
public function preview(int $id): JsonResponse
{
$tpl = EmailNotificationTemplate::findOrFail($id);
$html = $this->renderer->renderPreview($tpl);
return response()->json(['error' => false, 'html' => $html]);
}
public function fieldDefinitions(): JsonResponse
{
return response()->json([
'error' => false,
'data' => [
'block_types' => $this->blockTypeDefinitions(),
'block_field_definitions' => EmailNotificationTemplate::blockFieldDefinitions(),
'default_blocks' => EmailNotificationTemplate::defaultBlocks(),
],
]);
}
private function blockTypeDefinitions(): array
{
return [
// Dùng cho cả 2 loại
['type' => 'greeting', 'label' => 'Lời chào', 'description' => 'Chào hỏi khách hàng theo tên', 'for' => ['invoice_notification', 'vm_credentials']],
['type' => 'custom_text', 'label' => 'Văn bản tùy chỉnh', 'description' => 'Nội dung HTML hoặc text tự do', 'for' => ['invoice_notification', 'vm_credentials']],
['type' => 'footer', 'label' => 'Chữ ký kết thúc', 'description' => 'Trân trọng + tên công ty', 'for' => ['invoice_notification', 'vm_credentials']],
// invoice_notification
['type' => 'invoice_info', 'label' => 'Thông tin hóa đơn', 'description' => 'Số HĐ, ngày tạo, tổng tiền, trạng thái...', 'for' => ['invoice_notification']],
['type' => 'customer_info', 'label' => 'Thông tin khách hàng', 'description' => 'Tên, email, địa chỉ khách hàng', 'for' => ['invoice_notification']],
['type' => 'order_items', 'label' => 'Chi tiết sản phẩm', 'description' => 'Bảng sản phẩm từ đơn hàng', 'for' => ['invoice_notification']],
['type' => 'attachments', 'label' => 'File đính kèm', 'description' => 'Danh sách file đính kèm hóa đơn', 'for' => ['invoice_notification']],
// vm_credentials
['type' => 'vm_credentials', 'label' => 'Thông tin truy cập VPS', 'description' => 'IP, username, port, password, OS', 'for' => ['vm_credentials']],
['type' => 'product_info', 'label' => 'Thông tin dịch vụ', 'description' => 'Tên sản phẩm, chu kỳ, giá, hạn dùng', 'for' => ['vm_credentials']],
];
}
}
@@ -11,6 +11,7 @@ use App\Services\Email\EmailTemplateRenderer;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Log;
use Illuminate\Support\Facades\Mail;
use Illuminate\Validation\Rule;
@@ -105,6 +106,7 @@ class EmailTemplateController extends Controller
'invoice_template_id' => 'nullable|integer|exists:template,id',
'subject' => 'nullable|string|max:255',
'message' => 'nullable|string',
'builder_data' => 'nullable|array',
'status' => 'boolean',
'mail_config' => 'nullable|array',
'mail_config.from_email' => 'nullable|email',
@@ -126,6 +128,7 @@ class EmailTemplateController extends Controller
'invoice_template_id' => $data['invoice_template_id'] ?? null,
'subject' => $data['subject'] ?? null,
'message' => $data['message'] ?? null,
'builder_data' => $data['builder_data'] ?? null,
'status' => $data['status'] ?? 1,
'metadata' => isset($data['mail_config']) ? ['mail' => $data['mail_config']] : null,
]));
@@ -148,6 +151,7 @@ class EmailTemplateController extends Controller
'invoice_template_id' => 'sometimes|nullable|integer|exists:template,id',
'subject' => 'sometimes|nullable|string|max:255',
'message' => 'sometimes|nullable|string',
'builder_data' => 'sometimes|nullable|array',
'status' => 'sometimes|boolean',
'mail_config' => 'sometimes|nullable|array',
'mail_config.from_email' => 'nullable|email',
@@ -295,6 +299,12 @@ class EmailTemplateController extends Controller
} catch (InvalidTemplateVariableException $e) {
return response()->json(['error' => true, 'message' => $e->getMessage()], 422);
} catch (\Throwable $e) {
Log::error('Gửi email test thất bại', [
'template_id' => $id,
'to' => $data['to'],
'exception' => $e,
]);
return response()->json(['error' => true, 'message' => 'Không thể gửi email: ' . $e->getMessage()], 500);
}
@@ -349,6 +359,7 @@ class EmailTemplateController extends Controller
'invoice_template_name' => $t->invoiceTemplate?->name,
'subject' => $t->subject,
'message' => $t->message,
'builder_data' => $t->builder_data,
'status' => $t->status,
'variables' => array_values($t->variables()),
'mail_config' => $t->mailConfig(),
@@ -18,6 +18,7 @@ use App\Repositories\InvoiceAttachment\InvoiceAttachmentRepositoryInterface;
use App\Repositories\InvoiceItem\InvoiceItemRepositoryInterface;
use App\Services\InvoicePaymentService;
use App\Services\InvoicePdfService;
use App\Services\InvoiceReminderService;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\DB;
@@ -193,6 +194,7 @@ class InvoiceController extends Controller
if ($status === InvoiceStatus::PAID->value) {
$this->invoicePaymentService->markPaidByAdmin($invoice, auth()->id());
} else {
$this->assertCancellable($invoice);
$invoice->update([
'status' => $status,
'paid_at' => null,
@@ -205,6 +207,12 @@ class InvoiceController extends Controller
'error' => false,
'message' => 'Invoice updated successfully'
]);
} catch (\InvalidArgumentException $e) {
DB::rollBack();
return response()->json([
'error' => true,
'message' => $e->getMessage()
], 422);
} catch (\Exception $e) {
DB::rollBack();
return response()->json([
@@ -225,6 +233,15 @@ class InvoiceController extends Controller
], 404);
}
// Hoá đơn đã thu tiền là chứng từ gốc: xoá đi thì Transaction và số dư đã trừ vẫn còn,
// sổ sách lệch mà không còn dấu vết. Muốn đảo thì phải đi qua luồng hoàn tiền.
if (in_array((int) $invoice->status, [InvoiceStatus::PAID->value, InvoiceStatus::REFUNDED->value], true)) {
return response()->json([
'error' => true,
'message' => 'Không thể xoá hoá đơn đã thanh toán hoặc đã hoàn tiền. Hãy dùng chức năng hoàn tiền nếu cần đảo giao dịch.',
], 422);
}
try {
// Note: `orders` has no `invoice_id` column (the FK runs the other way —
// invoices.order_id -> orders.id), so there's nothing to clean up there. Any
@@ -244,7 +261,7 @@ class InvoiceController extends Controller
}
}
public function markAsPaid(int $id): JsonResponse
public function markAsPaid(Request $request, int $id): JsonResponse
{
$invoice = $this->invoiceRepository->find($id);
@@ -255,18 +272,42 @@ class InvoiceController extends Controller
], 404);
}
// Bỏ trống = thu đủ hoá đơn. Nhập số nhỏ hơn khi khách mới chuyển được một phần —
// hoá đơn ghi nhận trả một phần và chưa kích hoạt dịch vụ.
$validated = $request->validate([
'received_amount' => 'nullable|integer|min:1',
]);
try {
DB::beginTransaction();
$invoice->load(['order', 'customer']);
$this->invoicePaymentService->markPaidByAdmin($invoice, auth()->id());
$invoice = $this->invoicePaymentService->markPaidByAdmin(
$invoice,
auth()->id(),
receivedAmount: $validated['received_amount'] ?? null,
);
DB::commit();
$outstanding = InvoicePaymentService::outstanding($invoice);
return response()->json([
'error' => false,
'message' => 'Invoice marked as paid'
'message' => $outstanding > 0
? 'Đã ghi nhận thanh toán một phần. Còn thiếu ' . number_format($outstanding) . ' VNĐ.'
: 'Invoice marked as paid',
'data' => [
'amount_paid' => (int) $invoice->amount_paid,
'outstanding' => $outstanding,
],
]);
} catch (\InvalidArgumentException $e) {
DB::rollBack();
return response()->json([
'error' => true,
'message' => $e->getMessage()
], 422);
} catch (\Exception $e) {
DB::rollBack();
return response()->json([
@@ -288,6 +329,8 @@ class InvoiceController extends Controller
}
try {
$this->assertCancellable($invoice);
DB::beginTransaction();
$invoice->update([
'status' => InvoiceStatus::CANCELLED->value,
@@ -299,6 +342,12 @@ class InvoiceController extends Controller
'error' => false,
'message' => 'Invoice cancelled'
]);
} catch (\InvalidArgumentException $e) {
DB::rollBack();
return response()->json([
'error' => true,
'message' => $e->getMessage()
], 422);
} catch (\Exception $e) {
DB::rollBack();
return response()->json([
@@ -308,6 +357,98 @@ class InvoiceController extends Controller
}
}
/**
* Hoàn tiền hoá đơn đã thu — cộng lại số dư khách và ghi Transaction âm, thay cho việc
* huỷ hoá đơn đã thanh toán (nay đã bị chặn) hoặc xử lý tay ngoài hệ thống.
*/
public function refund(Request $request, int $id): JsonResponse
{
$invoice = $this->invoiceRepository->find($id);
if (!$invoice) {
return response()->json([
'error' => true,
'message' => "Can't find invoice by ID #" . $id
], 404);
}
$validated = $request->validate([
'amount' => 'nullable|integer|min:1',
'reason' => 'nullable|string|max:500',
]);
try {
$invoice = $this->invoicePaymentService->refund(
$invoice,
auth()->id(),
$validated['amount'] ?? null,
$validated['reason'] ?? '',
);
} catch (\InvalidArgumentException $e) {
return response()->json(['error' => true, 'message' => $e->getMessage()], 422);
} catch (\Throwable $e) {
Log::error('InvoiceController::refund error: ' . $e->getMessage(), ['invoice_id' => $id]);
return response()->json(['error' => true, 'message' => 'Không thể hoàn tiền, vui lòng thử lại.'], 500);
}
return response()->json([
'error' => false,
'message' => 'Đã hoàn tiền vào số dư khách hàng.',
'data' => ['invoice' => new InvoiceResource($invoice)],
]);
}
/**
* Nhắc thanh toán thủ công — dùng khi kế toán muốn thúc riêng một khách mà không chờ
* mốc tự động trong billing.reminder_offsets.
*/
public function remind(int $id, InvoiceReminderService $reminders): JsonResponse
{
$invoice = $this->invoiceRepository->find($id);
if (!$invoice) {
return response()->json([
'error' => true,
'message' => "Can't find invoice by ID #" . $id
], 404);
}
if (!$reminders->isRemindable($invoice)) {
return response()->json([
'error' => true,
'message' => 'Chỉ nhắc được hoá đơn chưa thanh toán hoặc quá hạn.',
], 422);
}
try {
$reminders->remind($invoice->load('customer'), force: true);
} catch (\Throwable $e) {
return response()->json([
'error' => true,
'message' => 'Không gửi được email nhắc: ' . $e->getMessage(),
], 500);
}
return response()->json([
'error' => false,
'message' => 'Đã gửi email nhắc thanh toán tới ' . $invoice->customer?->email,
]);
}
/**
* Huỷ hoá đơn đã thu tiền sẽ xoá paid_at nhưng không hoàn số dư, không revert order/service,
* trong khi Transaction vẫn còn — sổ sách lệch im lặng. Chặn ở đây, đảo giao dịch phải đi
* qua luồng hoàn tiền.
*/
private function assertCancellable(Invoice $invoice): void
{
if (in_array((int) $invoice->status, [InvoiceStatus::PAID->value, InvoiceStatus::REFUNDED->value], true)) {
throw new \InvalidArgumentException(
'Không thể huỷ hoá đơn đã thanh toán hoặc đã hoàn tiền. Hãy dùng chức năng hoàn tiền nếu cần đảo giao dịch.'
);
}
}
// ─── Attachments ────────────────────────────────────────────────────────────
public function listAttachments(int $id): JsonResponse
@@ -43,6 +43,27 @@ class SettingController extends Controller
]);
}
/**
* GET /settings/public
* Không cần token — chỉ trả các field công khai dùng cho trang đăng nhập.
*/
public function public(): JsonResponse
{
$setting = Setting::where('key', 'setting')->first();
$value = $setting ? json_decode((string) $setting->value, true) : [];
return response()->json([
'error' => false,
'data' => [
'title' => $value['title'] ?? '',
'company' => $value['company'] ?? '',
'logo' => $value['logo'] ?? '',
'favi_icon' => $value['favi_icon'] ?? '',
'brand_color' => $value['brand_color'] ?? '',
],
]);
}
/**
* PUT /administrator/settings
* Accepts: { setting: {...}, smtp: {...} }
@@ -105,6 +126,43 @@ class SettingController extends Controller
]);
}
/**
* GET /administrator/vat-settings
* Returns saved VAT rates per service type.
*/
public function vatIndex(): JsonResponse
{
$row = Setting::where('key', 'vat_settings')->first();
$data = $row ? json_decode((string) $row->value, true) : [];
return response()->json(['error' => false, 'data' => is_array($data) ? $data : []]);
}
/**
* PUT /administrator/vat-settings
* Accepts: { settings: [{ type, type_label, vat_rate }] }
*/
public function vatUpdate(Request $request): JsonResponse
{
$validator = Validator::make($request->all(), [
'settings' => 'required|array',
'settings.*.type' => 'required|string',
'settings.*.type_label' => 'sometimes|string|nullable',
'settings.*.vat_rate' => 'required|numeric|min:0|max:100',
]);
if ($validator->fails()) {
return response()->json(['error' => true, 'message' => $validator->errors()->first()], 422);
}
Setting::updateOrCreate(
['key' => 'vat_settings'],
['value' => json_encode($request->input('settings'), JSON_UNESCAPED_UNICODE)]
);
return response()->json(['error' => false, 'message' => 'Cập nhật thuế VAT thành công.']);
}
/**
* POST /administrator/settings/test-telegram
* Sends a test message using the currently saved Telegram config.
@@ -57,6 +57,10 @@ class AdminTransactionController extends Controller
'public_code' => $t->public_code,
'trans' => $t->trans,
'amount' => $t->amount,
// Số thực nhận trên sao kê; null = chưa duyệt. Chênh với `amount` chính là
// phần khách chuyển thiếu/dư so với số đã khai.
'received_amount' => $t->received_amount === null ? null : (int) $t->received_amount,
'note' => $t->note,
'type' => $t->type,
'status' => $t->status,
'status_label' => TransactionStatus::from((int) $t->status)->label(),
@@ -84,12 +88,19 @@ class AdminTransactionController extends Controller
]);
}
public function approve(string $uuid): JsonResponse
public function approve(Request $request, string $uuid): JsonResponse
{
// Số tiền THỰC NHẬN trên sao kê. Bỏ trống = tin số khách khai (hành vi cũ), nhưng
// khi khách chuyển thiếu thì admin nhập đúng số đã về để số dư không bị cộng khống.
$validated = $request->validate([
'received_amount' => 'nullable|integer|min:0',
'note' => 'nullable|string|max:1000',
]);
try {
// Lock the transaction (and customer) row for the whole read-check-write so two
// admins approving the same transaction concurrently cannot both credit the customer.
$transaction = DB::transaction(function () use ($uuid) {
$transaction = DB::transaction(function () use ($uuid, $validated) {
$transaction = Transaction::where('uuid', $uuid)
->where('type', 'manual')
->lockForUpdate()
@@ -99,18 +110,47 @@ class AdminTransactionController extends Controller
return null;
}
$transaction->update(['status' => TransactionStatus::COMPLETED->value]);
$declared = (int) $transaction->amount;
$received = $validated['received_amount'] ?? $declared;
$shortfall = $declared - $received;
$note = trim(implode(' ', array_filter([
$transaction->note,
$validated['note'] ?? null,
$shortfall !== 0
? sprintf(
'[Lệch %s VNĐ: khai %s, thực nhận %s]',
number_format(abs($shortfall)),
number_format($declared),
number_format($received),
)
: null,
])));
$transaction->update([
'status' => TransactionStatus::COMPLETED->value,
'received_amount' => $received,
'note' => $note !== '' ? $note : null,
]);
$customer = Customer::where('id', $transaction->customer_id)->lockForUpdate()->first();
$customer->total += $transaction->amount;
$customer->credit += (int) floor($transaction->amount / 100);
$customer->total_pending = max(0, $customer->total_pending - $transaction->amount);
$creditEarned = Customer::creditFor($received);
$customer->total += $received;
$customer->credit += $creditEarned;
// Trừ pending theo số khách khai — đó chính là số đã được giữ chỗ lúc tạo yêu cầu.
$customer->total_pending = max(0, $customer->total_pending - $declared);
$customer->save();
Log::create([
'agent' => 'admin',
'client_ip' => request()->ip(),
'description' => 'Admin duyệt nạp tiền thủ công: ' . number_format($transaction->amount) . ' VNĐ (+' . floor($transaction->amount / 100) . ' credit)',
'description' => sprintf(
'Admin duyệt nạp tiền thủ công: thực nhận %s VNĐ (khai %s VNĐ) (+%d credit)%s',
number_format($received),
number_format($declared),
$creditEarned,
$shortfall > 0 ? ' — THIẾU ' . number_format($shortfall) . ' VNĐ' : '',
),
'customer_id' => $customer->id,
]);
@@ -121,7 +161,7 @@ class AdminTransactionController extends Controller
'link' => '/lich-su-giao-dich/' . $transaction->id,
'data' => [
'transaction_uuid' => $transaction->uuid,
'amount' => $transaction->amount,
'amount' => $received,
'public_code' => $transaction->public_code,
],
]);
@@ -151,7 +191,7 @@ class AdminTransactionController extends Controller
'customer_email' => $customer->email,
'customer_name' => $customer->fullname,
'transaction_code' => $transaction->public_code,
'amount' => $transaction->amount,
'amount' => $transaction->received_amount ?? $transaction->amount,
'status_label' => 'Đã duyệt',
'status_color' => '#15803d',
'status_bg' => '#f0fdf4',