feat(admin): improve payments and server settings
This commit is contained in:
@@ -25,6 +25,31 @@ enum ProductBillingCycle: string
|
||||
};
|
||||
}
|
||||
|
||||
public function vietnameseLabel(): string
|
||||
{
|
||||
return match ($this) {
|
||||
self::ONE_TIME => 'Một lần',
|
||||
self::MONTHLY => 'Hàng tháng',
|
||||
self::QUARTERLY => '3 tháng',
|
||||
self::SEMI_ANNUALLY => '6 tháng',
|
||||
self::ANNUALLY => 'Hàng năm',
|
||||
self::BIENNIALLY => '2 năm',
|
||||
self::TRIENNIALLY => '3 năm',
|
||||
};
|
||||
}
|
||||
|
||||
public static function vietnameseLabelFor(?string $code): string
|
||||
{
|
||||
return self::tryFrom((string) $code)?->vietnameseLabel() ?? ($code ?: 'N/A');
|
||||
}
|
||||
|
||||
public static function vietnameseLabelForMonths(int $months): string
|
||||
{
|
||||
$cycle = collect(self::cases())->first(fn (self $case) => $case->months() === $months && $months > 0);
|
||||
|
||||
return $cycle?->vietnameseLabel() ?? $months . ' tháng';
|
||||
}
|
||||
|
||||
public function months(): int
|
||||
{
|
||||
return match ($this) {
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Events;
|
||||
|
||||
use Illuminate\Broadcasting\InteractsWithSockets;
|
||||
use Illuminate\Broadcasting\PrivateChannel;
|
||||
use Illuminate\Contracts\Broadcasting\ShouldBroadcastNow;
|
||||
use Illuminate\Foundation\Events\Dispatchable;
|
||||
use Illuminate\Queue\SerializesModels;
|
||||
|
||||
class AdminNotificationCreated implements ShouldBroadcastNow
|
||||
{
|
||||
use Dispatchable, InteractsWithSockets, SerializesModels;
|
||||
|
||||
public function __construct(
|
||||
public readonly string $type,
|
||||
public readonly string $title,
|
||||
public readonly ?string $body = null,
|
||||
public readonly ?string $link = null,
|
||||
) {}
|
||||
|
||||
public function broadcastOn(): array
|
||||
{
|
||||
return [new PrivateChannel('admin-notifications')];
|
||||
}
|
||||
|
||||
public function broadcastAs(): string
|
||||
{
|
||||
return 'admin.notification.created';
|
||||
}
|
||||
|
||||
public function broadcastWith(): array
|
||||
{
|
||||
return [
|
||||
'type' => $this->type,
|
||||
'title' => $this->title,
|
||||
'body' => $this->body,
|
||||
'link' => $this->link,
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -12,6 +12,8 @@ use Illuminate\Support\Facades\DB;
|
||||
|
||||
class ServerController extends Controller
|
||||
{
|
||||
private const DOMAIN_PATTERN = '/^(?=.{1,253}$)([a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?\.)+[a-z]{2,63}$/i';
|
||||
|
||||
public function __construct(
|
||||
private readonly DnsServerRepositoryInterface $serverRepository,
|
||||
private readonly ServerService $serverService,
|
||||
@@ -30,6 +32,7 @@ class ServerController extends Controller
|
||||
$query->where(function ($subQuery) use ($filter) {
|
||||
$subQuery->where('name', 'like', "%{$filter}%")
|
||||
->orWhere('ip_address', 'like', "%{$filter}%")
|
||||
->orWhere('domain', 'like', "%{$filter}%")
|
||||
->orWhere('username', 'like', "%{$filter}%");
|
||||
});
|
||||
}
|
||||
@@ -79,6 +82,7 @@ class ServerController extends Controller
|
||||
$validated = $request->validate([
|
||||
'name' => 'required|string|max:255',
|
||||
'ip_address' => 'required|string|max:255',
|
||||
'domain' => ['nullable', 'string', 'max:255', 'regex:' . self::DOMAIN_PATTERN],
|
||||
'panel_port' => 'nullable|integer|min:1|max:65535',
|
||||
'username' => 'required|string|max:255',
|
||||
'password' => 'required|string|max:255',
|
||||
@@ -93,6 +97,7 @@ class ServerController extends Controller
|
||||
$server = $this->serverRepository->create([
|
||||
'name' => $validated['name'],
|
||||
'ip_address' => $validated['ip_address'],
|
||||
'domain' => $validated['domain'] ?? null,
|
||||
'panel_port' => $validated['panel_port'] ?? 8880,
|
||||
'username' => $validated['username'],
|
||||
'password' => Crypt::encryptString($validated['password']),
|
||||
@@ -125,6 +130,7 @@ class ServerController extends Controller
|
||||
$validated = $request->validate([
|
||||
'name' => 'sometimes|required|string|max:255',
|
||||
'ip_address' => 'sometimes|required|string|max:255',
|
||||
'domain' => ['nullable', 'string', 'max:255', 'regex:' . self::DOMAIN_PATTERN],
|
||||
'panel_port' => 'nullable|integer|min:1|max:65535',
|
||||
'username' => 'sometimes|required|string|max:255',
|
||||
'password' => 'nullable|string|max:255',
|
||||
@@ -194,6 +200,7 @@ class ServerController extends Controller
|
||||
'id' => $server->id,
|
||||
'name' => $server->name,
|
||||
'ip_address' => $server->ip_address,
|
||||
'domain' => $server->domain,
|
||||
'panel_port' => $server->panel_port,
|
||||
'username' => $server->username,
|
||||
'api_key' => $server->api_key,
|
||||
|
||||
@@ -101,6 +101,10 @@ class SettingController extends Controller
|
||||
'smtp.encryption' => 'sometimes|in:ssl,tls,starttls,',
|
||||
'smtp.from_name' => 'sometimes|string|nullable',
|
||||
'smtp.from_address' => 'sometimes|email|nullable',
|
||||
'smtp.cc' => 'sometimes|array',
|
||||
'smtp.cc.*' => 'email|max:255|distinct',
|
||||
'smtp.bcc' => 'sometimes|array',
|
||||
'smtp.bcc.*' => 'email|max:255|distinct',
|
||||
'turnstile' => 'sometimes|array',
|
||||
'turnstile.enabled' => 'sometimes|boolean',
|
||||
'turnstile.site_key' => 'sometimes|string|nullable|max:255',
|
||||
|
||||
@@ -20,6 +20,7 @@ use App\Services\Api\Order\OrderQueryService;
|
||||
use App\Services\Api\Order\OrderService;
|
||||
use App\Services\InvoicePaymentService;
|
||||
use App\Services\InvoicePdfService;
|
||||
use App\Services\AdminPaymentNotifier;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
@@ -32,6 +33,7 @@ class OrderControllerRefactor extends Controller
|
||||
private readonly OrderQueryService $orderQueryService,
|
||||
private readonly ActivityLogger $activityLogger,
|
||||
private readonly InvoicePaymentService $invoicePaymentService,
|
||||
private readonly AdminPaymentNotifier $adminPaymentNotifier,
|
||||
) {}
|
||||
|
||||
public function getList(Request $request): JsonResponse
|
||||
@@ -234,6 +236,7 @@ class OrderControllerRefactor extends Controller
|
||||
]);
|
||||
|
||||
try {
|
||||
$amountPaidBefore = (int) $invoice->amount_paid;
|
||||
$paidInvoice = $this->invoicePaymentService->payWithBalance(
|
||||
$invoice,
|
||||
$request->user(),
|
||||
@@ -250,6 +253,11 @@ class OrderControllerRefactor extends Controller
|
||||
);
|
||||
|
||||
$outstanding = \App\Services\InvoicePaymentService::outstanding($paidInvoice);
|
||||
$this->adminPaymentNotifier->notify(
|
||||
$paidInvoice,
|
||||
(int) $paidInvoice->amount_paid - $amountPaidBefore,
|
||||
'invoice',
|
||||
);
|
||||
|
||||
return response()->json([
|
||||
'success' => true,
|
||||
|
||||
@@ -9,6 +9,7 @@ use App\Http\Services\Telegram;
|
||||
use App\Mail\Admin\ManualDepositPendingNotification;
|
||||
use App\Mail\Customer\ManualDepositStatus;
|
||||
use App\Models\Log;
|
||||
use App\Models\Notification;
|
||||
use App\Models\Setting;
|
||||
use App\Models\Transaction;
|
||||
use App\Services\ActivityLogger;
|
||||
@@ -229,13 +230,34 @@ class ManualDepositController extends Controller
|
||||
], 422);
|
||||
}
|
||||
|
||||
Notification::notifyAdmins([
|
||||
'type' => 'manual_deposit_pending',
|
||||
'title' => 'Có yêu cầu nạp tiền chờ duyệt',
|
||||
'body' => sprintf(
|
||||
'%s vừa gửi chứng từ nạp %s VNĐ (%s).',
|
||||
$user->fullname,
|
||||
number_format((float) $transaction->amount, 0, ',', '.'),
|
||||
$transaction->public_code,
|
||||
),
|
||||
'link' => '/transactions',
|
||||
'data' => [
|
||||
'transaction_uuid' => $transaction->uuid,
|
||||
'transaction_code' => $transaction->public_code,
|
||||
'customer_id' => $user->id,
|
||||
'amount' => (int) $transaction->amount,
|
||||
],
|
||||
]);
|
||||
|
||||
try {
|
||||
(new Telegram())->SendManualDepositPendingApproval($user, $transaction);
|
||||
$this->sendManualDepositAdminEmail($user, $transaction, $request);
|
||||
} catch (\Exception) {
|
||||
// Không để lỗi Telegram ảnh hưởng response
|
||||
} catch (\Throwable $e) {
|
||||
\Log::error('Không thể gửi Telegram thông báo nạp tiền chờ duyệt: ' . $e->getMessage(), [
|
||||
'transaction_code' => $transaction->public_code,
|
||||
]);
|
||||
}
|
||||
|
||||
$this->sendManualDepositAdminEmail($user, $transaction, $request);
|
||||
|
||||
Log::create([
|
||||
'agent' => 'api',
|
||||
'client_ip' => $request->ip(),
|
||||
|
||||
@@ -267,6 +267,7 @@ class TransactionController extends Controller
|
||||
'public_code' => $t->public_code,
|
||||
'trans' => $t->trans,
|
||||
'description' => $t->typeLabel(),
|
||||
'direction' => $t->direction,
|
||||
'amount' => (float) $t->amount,
|
||||
'status' => $t->status,
|
||||
'status_label' => TransactionStatus::from((int) $t->status)->label(),
|
||||
@@ -283,6 +284,7 @@ class TransactionController extends Controller
|
||||
'public_code' => $inv->invoice_number,
|
||||
'trans' => null,
|
||||
'description' => 'Thanh toán đơn hàng: ' . ($inv->order?->orderItems->first()?->product?->name ?? $inv->order?->order_num),
|
||||
'direction' => 'debit',
|
||||
'amount' => (float) $inv->total,
|
||||
'status' => $inv->status,
|
||||
'status_label' => \App\Enums\Invoice\InvoiceStatus::tryFrom((int) $inv->status)?->label() ?? '-',
|
||||
@@ -368,4 +370,4 @@ class TransactionController extends Controller
|
||||
],
|
||||
]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -126,9 +126,9 @@ class ServiceClientDetailResource extends JsonResource
|
||||
'dns' => $vm->dns ?? null,
|
||||
] : null,
|
||||
'hosting' => $hosting ? [
|
||||
'ip_address' => $hosting->server?->ip_address,
|
||||
'plesk_url' => $hosting->server?->ip_address
|
||||
? "http://{$hosting->server->ip_address}:{$hosting->server->panel_port}/"
|
||||
'ip_address' => $hosting->server?->publicHost(),
|
||||
'plesk_url' => $hosting->server
|
||||
? "http://{$hosting->server->publicHost()}:{$hosting->server->panel_port}/"
|
||||
: null,
|
||||
'hosting_plan' => $hosting->hosting_plan,
|
||||
'ftp_username' => $hosting->ftp_username,
|
||||
|
||||
@@ -0,0 +1,49 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Jobs\Mail\Admin;
|
||||
|
||||
use App\Mail\Admin\AdminPaymentReceived;
|
||||
use App\Models\Setting;
|
||||
use Illuminate\Bus\Queueable;
|
||||
use Illuminate\Contracts\Queue\ShouldQueue;
|
||||
use Illuminate\Foundation\Bus\Dispatchable;
|
||||
use Illuminate\Queue\InteractsWithQueue;
|
||||
use Illuminate\Queue\SerializesModels;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
use Illuminate\Support\Facades\Mail;
|
||||
|
||||
class AdminPaymentReceivedJob implements ShouldQueue
|
||||
{
|
||||
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
|
||||
|
||||
public int $tries = 3;
|
||||
public int $timeout = 60;
|
||||
|
||||
public function __construct(public readonly array $data) {}
|
||||
|
||||
public function handle(): void
|
||||
{
|
||||
$setting = Setting::where('key', 'setting')->first();
|
||||
$values = $setting ? json_decode((string) $setting->value, true) : [];
|
||||
$adminEmail = $values['admin_email'] ?? null;
|
||||
|
||||
if (!$adminEmail) {
|
||||
Log::warning('AdminPaymentReceivedJob: admin_email not configured in settings.');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
Mail::to($adminEmail)->send(new AdminPaymentReceived([
|
||||
...$this->data,
|
||||
'company_name' => $values['company'] ?? config('app.name'),
|
||||
]));
|
||||
} catch (\Throwable $e) {
|
||||
Log::error('AdminPaymentReceivedJob: failed to send email.', [
|
||||
'reference' => $this->data['reference'] ?? null,
|
||||
'error' => $e->getMessage(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -5,6 +5,7 @@ declare(strict_types=1);
|
||||
namespace App\Jobs\VirtualMachine\Provision;
|
||||
|
||||
use App\Http\Services\Telegram;
|
||||
use App\Enums\Product\ProductBillingCycle;
|
||||
use App\Jobs\Mail\Admin\AdminOrderNotificationJob;
|
||||
use App\Jobs\Mail\SendInformationVirtualMachine;
|
||||
use App\Models\Invoice;
|
||||
@@ -97,7 +98,7 @@ class SendVmCredentialsEmailJob implements ShouldQueue
|
||||
'order_num' => $order?->order_num ?? $order?->id ?? 'N/A',
|
||||
'product_name' => $orderItem?->getProductAttribute()?->name ?? 'Cloud VPS',
|
||||
'product_type' => 'Cloud VPS',
|
||||
'billing_cycle' => $this->billingLabel($months),
|
||||
'billing_cycle' => ProductBillingCycle::vietnameseLabelForMonths($months),
|
||||
'total' => $orderItem?->price ?? 0,
|
||||
'created_at' => $order?->created_at?->format('d/m/Y H:i:s') ?? now()->format('d/m/Y H:i:s'),
|
||||
'ip' => $vm->ip ?? '',
|
||||
@@ -171,15 +172,4 @@ class SendVmCredentialsEmailJob implements ShouldQueue
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
|
||||
private function billingLabel(?int $months): string
|
||||
{
|
||||
return match ($months) {
|
||||
1 => 'Hang thang',
|
||||
3 => '3 Thang',
|
||||
6 => '6 Thang',
|
||||
12 => 'Hang nam',
|
||||
24 => '2 Nam',
|
||||
default => ($months ?? 0) . ' Thang',
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@ declare(strict_types=1);
|
||||
namespace App\Jobs\VirtualMachine\Rebuild;
|
||||
|
||||
use App\Http\Services\Telegram;
|
||||
use App\Enums\Product\ProductBillingCycle;
|
||||
use App\Jobs\Mail\Admin\AdminOrderNotificationJob;
|
||||
use App\Jobs\Mail\Customer\VmRebuildFailedJob;
|
||||
use App\Jobs\Mail\SendInformationVirtualMachine;
|
||||
@@ -157,7 +158,7 @@ final class RebuildFinalizeJob implements ShouldQueue
|
||||
'company_logo' => $settingValue->logo ?? '',
|
||||
'client_name' => $vm->customer->fullname ?? '',
|
||||
'product_name' => $orderItem?->getProductAttribute()?->name ?? 'Cloud VPS',
|
||||
'product_billing' => $this->billingLabel($months),
|
||||
'product_billing' => ProductBillingCycle::vietnameseLabelForMonths($months),
|
||||
'product_price' => $orderItem ? number_format((float) $orderItem->price, 0, ',', '.') : 'N/A',
|
||||
'service_host' => $vm->ip ?? 'N/A',
|
||||
'service_username' => $osUsername,
|
||||
@@ -180,7 +181,7 @@ final class RebuildFinalizeJob implements ShouldQueue
|
||||
'order_num' => $vm->order?->order_num ?? $vm->order?->id ?? 'N/A',
|
||||
'product_name' => ($orderItem?->getProductAttribute()?->name ?? 'Cloud VPS') . ' (Rebuild)',
|
||||
'product_type' => 'Cloud VPS Rebuild',
|
||||
'billing_cycle' => $this->billingLabel($months),
|
||||
'billing_cycle' => ProductBillingCycle::vietnameseLabelForMonths($months),
|
||||
'total' => $orderItem?->price ?? 0,
|
||||
'created_at' => now()->format('d/m/Y H:i:s'),
|
||||
'ip' => $vm->ip ?? '',
|
||||
@@ -188,17 +189,4 @@ final class RebuildFinalizeJob implements ShouldQueue
|
||||
'password' => $vm->plain_password ?? '',
|
||||
]);
|
||||
}
|
||||
|
||||
private function billingLabel(int $months): string
|
||||
{
|
||||
return match ($months) {
|
||||
1 => 'Hang thang',
|
||||
3 => '3 Thang',
|
||||
6 => '6 Thang',
|
||||
12 => 'Hang nam',
|
||||
24 => '2 Nam',
|
||||
default => $months . ' Thang',
|
||||
};
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
<?php
|
||||
|
||||
namespace App\Listeners\Mail;
|
||||
|
||||
use App\Support\MailSettings;
|
||||
use Illuminate\Mail\Events\MessageSending;
|
||||
|
||||
class ApplySystemRecipients
|
||||
{
|
||||
public function handle(MessageSending $event): void
|
||||
{
|
||||
$recipients = MailSettings::recipients();
|
||||
$existing = collect([
|
||||
...$event->message->getTo(),
|
||||
...$event->message->getCc(),
|
||||
...$event->message->getBcc(),
|
||||
])->map(fn ($address) => strtolower($address->getAddress()))->all();
|
||||
|
||||
foreach ($recipients['cc'] as $email) {
|
||||
if (!in_array($email, $existing, true)) {
|
||||
$event->message->addCc($email);
|
||||
$existing[] = $email;
|
||||
}
|
||||
}
|
||||
|
||||
foreach ($recipients['bcc'] as $email) {
|
||||
if (!in_array($email, $existing, true)) {
|
||||
$event->message->addBcc($email);
|
||||
$existing[] = $email;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -4,6 +4,7 @@ declare(strict_types=1);
|
||||
|
||||
namespace App\Mail\Admin;
|
||||
|
||||
use App\Enums\Product\ProductBillingCycle;
|
||||
use App\Mail\TemplateMailable;
|
||||
use App\Support\EmailTemplateShell;
|
||||
use Illuminate\Bus\Queueable;
|
||||
@@ -45,7 +46,7 @@ class AdminOrderNotification extends TemplateMailable
|
||||
: '',
|
||||
'product_name' => $this->data['product_name'] ?? 'N/A',
|
||||
'product_type' => $this->data['product_type'] ?? 'N/A',
|
||||
'billing_cycle' => $this->data['billing_cycle'] ?? 'N/A',
|
||||
'billing_cycle' => ProductBillingCycle::vietnameseLabelFor($this->data['billing_cycle'] ?? null),
|
||||
'total_formatted' => number_format((float) ($this->data['total'] ?? 0), 0, ',', '.') . ' VNĐ',
|
||||
'vm_block' => $vmRows
|
||||
? EmailTemplateShell::heading('Thông tin dịch vụ Cloud VPS') . EmailTemplateShell::infoTable([], $vmRows)
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Mail\Admin;
|
||||
|
||||
use App\Mail\TemplateMailable;
|
||||
use Illuminate\Bus\Queueable;
|
||||
use Illuminate\Queue\SerializesModels;
|
||||
|
||||
class AdminPaymentReceived extends TemplateMailable
|
||||
{
|
||||
use Queueable, SerializesModels;
|
||||
|
||||
public function __construct(public readonly array $data) {}
|
||||
|
||||
public function attachments(): array
|
||||
{
|
||||
return [];
|
||||
}
|
||||
|
||||
protected function scenario(): string
|
||||
{
|
||||
return 'admin_payment_received';
|
||||
}
|
||||
|
||||
protected function variables(): array
|
||||
{
|
||||
return [
|
||||
'company_name' => $this->data['company_name'] ?? config('app.name'),
|
||||
'payment_type' => $this->data['payment_type'] ?? 'Hóa đơn',
|
||||
'reference' => $this->data['reference'] ?? 'N/A',
|
||||
'invoice_number' => $this->data['invoice_number'] ?? 'N/A',
|
||||
'customer_name' => $this->data['customer_name'] ?? 'N/A',
|
||||
'customer_email' => $this->data['customer_email'] ?? 'N/A',
|
||||
'amount_formatted' => number_format((float) ($this->data['amount'] ?? 0), 0, ',', '.') . ' VNĐ',
|
||||
'outstanding_formatted' => number_format((float) ($this->data['outstanding'] ?? 0), 0, ',', '.') . ' VNĐ',
|
||||
'payment_status' => ($this->data['outstanding'] ?? 0) > 0 ? 'Thanh toán một phần' : 'Đã thanh toán đủ',
|
||||
'created_at' => $this->data['created_at'] ?? now()->format('d/m/Y H:i:s'),
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -16,6 +16,7 @@ class DnsServer extends Model
|
||||
protected $fillable = [
|
||||
'name',
|
||||
'ip_address',
|
||||
'domain',
|
||||
'panel_port',
|
||||
'password',
|
||||
'username',
|
||||
@@ -31,6 +32,11 @@ class DnsServer extends Model
|
||||
return in_array($type, [ServerType::HOSTING->value, ServerType::BOTH->value], true);
|
||||
}
|
||||
|
||||
public function publicHost(): string
|
||||
{
|
||||
return $this->domain ?: $this->ip_address;
|
||||
}
|
||||
|
||||
protected function scopeIsActive($query)
|
||||
{
|
||||
return $query->where('status', 'activated');
|
||||
|
||||
@@ -2,11 +2,11 @@
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use App\Events\AdminNotificationCreated;
|
||||
use Illuminate\Database\Eloquent\Concerns\HasUuids;
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use App\Models\Customer;
|
||||
use App\Models\User;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
|
||||
class Notification extends Model
|
||||
{
|
||||
@@ -58,5 +58,23 @@ class Notification extends Model
|
||||
...$attributes,
|
||||
]);
|
||||
}
|
||||
|
||||
if ($adminIds->isEmpty()) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
broadcast(new AdminNotificationCreated(
|
||||
type: (string) ($attributes['type'] ?? 'system'),
|
||||
title: (string) ($attributes['title'] ?? 'Có thông báo mới'),
|
||||
body: isset($attributes['body']) ? (string) $attributes['body'] : null,
|
||||
link: isset($attributes['link']) ? (string) $attributes['link'] : null,
|
||||
));
|
||||
} catch (\Throwable $e) {
|
||||
Log::warning('Không thể phát thông báo realtime cho quản trị viên.', [
|
||||
'type' => $attributes['type'] ?? null,
|
||||
'error' => $e->getMessage(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -30,7 +30,7 @@ class Service extends Model
|
||||
protected $table = 'services';
|
||||
|
||||
public const HOSTING_ACCOUNT_RELATIONS = [
|
||||
'hostingAccount.server:id,name,ip_address',
|
||||
'hostingAccount.server:id,name,ip_address,domain,panel_port',
|
||||
'hostingAccount.domain:id,domain_name',
|
||||
];
|
||||
|
||||
|
||||
@@ -7,6 +7,7 @@ use Illuminate\Auth\Listeners\SendEmailVerificationNotification;
|
||||
use Illuminate\Foundation\Support\Providers\EventServiceProvider as ServiceProvider;
|
||||
use Illuminate\Mail\Events\MessageSending;
|
||||
use App\Listeners\Mail\CheckMailEnabled;
|
||||
use App\Listeners\Mail\ApplySystemRecipients;
|
||||
|
||||
class EventServiceProvider extends ServiceProvider
|
||||
{
|
||||
@@ -21,6 +22,7 @@ class EventServiceProvider extends ServiceProvider
|
||||
],
|
||||
MessageSending::class => [
|
||||
CheckMailEnabled::class,
|
||||
ApplySystemRecipients::class,
|
||||
],
|
||||
];
|
||||
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Services;
|
||||
|
||||
use App\Jobs\Mail\Admin\AdminPaymentReceivedJob;
|
||||
use App\Models\Invoice;
|
||||
use App\Models\Notification;
|
||||
|
||||
class AdminPaymentNotifier
|
||||
{
|
||||
public function notify(Invoice $invoice, int $amount, string $paymentType): void
|
||||
{
|
||||
$invoice->loadMissing(['customer', 'order']);
|
||||
$outstanding = InvoicePaymentService::outstanding($invoice);
|
||||
$reference = $invoice->order?->order_num ?? $invoice->invoice_number;
|
||||
$label = $paymentType === 'order' ? 'đơn hàng' : 'hóa đơn';
|
||||
$amountFormatted = number_format($amount, 0, ',', '.') . ' VNĐ';
|
||||
|
||||
Notification::notifyAdmins([
|
||||
'type' => 'payment_received',
|
||||
'title' => 'Khách hàng thanh toán ' . $label,
|
||||
'body' => "{$invoice->customer->fullname} đã thanh toán {$amountFormatted} cho {$label} #{$reference}.",
|
||||
'link' => '/order-invoice',
|
||||
'data' => [
|
||||
'invoice_id' => $invoice->id,
|
||||
'order_id' => $invoice->order_id,
|
||||
'amount' => $amount,
|
||||
'outstanding' => $outstanding,
|
||||
],
|
||||
]);
|
||||
|
||||
AdminPaymentReceivedJob::dispatch([
|
||||
'payment_type' => $paymentType === 'order' ? 'Đơn hàng' : 'Hóa đơn',
|
||||
'reference' => $reference,
|
||||
'invoice_number' => $invoice->invoice_number,
|
||||
'customer_name' => (string) $invoice->customer->fullname,
|
||||
'customer_email' => (string) $invoice->customer->email,
|
||||
'amount' => $amount,
|
||||
'outstanding' => $outstanding,
|
||||
'created_at' => now()->format('d/m/Y H:i:s'),
|
||||
])->afterCommit();
|
||||
}
|
||||
}
|
||||
@@ -2,19 +2,18 @@
|
||||
|
||||
namespace App\Services\Api\Order;
|
||||
|
||||
use App\Enums\Invoice\InvoiceStatus;
|
||||
use App\Enums\Order\OrderStatus;
|
||||
use App\Helpers\OrderHelper;
|
||||
use App\Jobs\Mail\Admin\AdminOrderNotificationJob;
|
||||
use App\Models\Order;
|
||||
use App\Models\Product;
|
||||
use App\Repositories\Api\Invoice\InvoiceRepositoryInterface;
|
||||
use App\Repositories\Api\Order\OrderRepositoryInterface;
|
||||
use App\Repositories\Api\Service\ServiceRepository;
|
||||
use App\Services\InvoicePaymentService;
|
||||
use App\Services\AdminPaymentNotifier;
|
||||
use App\Support\AddonQuantityValidator;
|
||||
use Exception;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
|
||||
class OrderService
|
||||
{
|
||||
@@ -23,6 +22,7 @@ class OrderService
|
||||
protected ServiceRepository $productRepo,
|
||||
protected InvoiceRepositoryInterface $invoiceRepo,
|
||||
protected InvoicePaymentService $invoicePaymentService,
|
||||
protected AdminPaymentNotifier $adminPaymentNotifier,
|
||||
) {}
|
||||
|
||||
// =========================================================================
|
||||
@@ -110,7 +110,7 @@ class OrderService
|
||||
throw new \InvalidArgumentException('Đơn hàng đã được thanh toán hoặc đang xử lý.');
|
||||
}
|
||||
|
||||
return DB::transaction(function () use ($order, $method) {
|
||||
$processedOrder = DB::transaction(function () use ($order, $method) {
|
||||
$customer = $order->customer;
|
||||
|
||||
if ($customer->total < $order->total) {
|
||||
@@ -121,22 +121,28 @@ class OrderService
|
||||
));
|
||||
}
|
||||
|
||||
// Allow re-paying a cancelled order
|
||||
$invoice = $order->invoice;
|
||||
if (! $invoice) {
|
||||
throw new \InvalidArgumentException('Không tìm thấy hóa đơn của đơn hàng.');
|
||||
}
|
||||
|
||||
if ((int) $invoice->status === InvoiceStatus::PAID->value) {
|
||||
throw new \InvalidArgumentException('Đơn hàng đã được thanh toán hoặc đang xử lý.');
|
||||
}
|
||||
|
||||
if ($order->status === OrderStatus::CANCELLED->value) {
|
||||
$order->update(['status' => OrderStatus::PENDING->value]);
|
||||
}
|
||||
|
||||
if (! $order->invoice) {
|
||||
throw new \InvalidArgumentException('Không tìm thấy hóa đơn của đơn hàng.');
|
||||
}
|
||||
|
||||
$this->invoicePaymentService->payWithBalance($order->invoice, $customer, $method);
|
||||
$paidInvoice = $this->invoicePaymentService->payWithBalance($invoice, $customer, $method);
|
||||
$processedOrder = $order->fresh(['orderItems', 'invoice', 'customer']);
|
||||
|
||||
$this->dispatchAdminNotification($processedOrder, $customer);
|
||||
|
||||
return $processedOrder;
|
||||
});
|
||||
|
||||
$this->adminPaymentNotifier->notify($processedOrder->invoice, (int) $processedOrder->total, 'order');
|
||||
|
||||
return $processedOrder;
|
||||
}
|
||||
|
||||
|
||||
@@ -145,32 +151,6 @@ class OrderService
|
||||
// =========================================================================
|
||||
|
||||
|
||||
private function dispatchAdminNotification(Order $order, $customer): void
|
||||
{
|
||||
try {
|
||||
$item = $order->orderItems->first();
|
||||
$product = $item?->product;
|
||||
$productType = $product?->category?->name ?? $product?->type ?? 'N/A';
|
||||
|
||||
AdminOrderNotificationJob::dispatch([
|
||||
'order_num' => $order->order_num,
|
||||
'customer_name' => (string) $customer->fullname,
|
||||
'customer_email' => $customer->email,
|
||||
'client_ip' => $order->client_ip ?? request()?->ip(),
|
||||
'product_name' => $product?->name ?? 'N/A',
|
||||
'product_type' => $productType,
|
||||
'billing_cycle' => $item?->billing_cycle ?? 'N/A',
|
||||
'total' => $order->total,
|
||||
'created_at' => now()->format('d/m/Y H:i:s'),
|
||||
]);
|
||||
} catch (Exception $e) {
|
||||
Log::error('OrderService: failed to dispatch admin notification', [
|
||||
'order_id' => $order->id,
|
||||
'error' => $e->getMessage(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Enrich raw addon entries with resolved price & subtotal.
|
||||
*
|
||||
|
||||
@@ -137,12 +137,12 @@ class PleskHostingService
|
||||
'plesk_login' => $pleskLogin,
|
||||
'plesk_password' => $pleskPassword,
|
||||
// Plesk's actual login page — not the :8443 REST API entry point.
|
||||
'plesk_panel_url' => 'https://' . $server->ip_address . '/login_up.php',
|
||||
'plesk_panel_url' => 'https://' . $server->publicHost() . '/login_up.php',
|
||||
'created_at' => now()->format('d/m/Y H:i:s'),
|
||||
];
|
||||
|
||||
dispatch(new HostingAccountCreatedJob(array_merge($shared, [
|
||||
'server_host' => $server->ip_address,
|
||||
'server_host' => $server->publicHost(),
|
||||
])));
|
||||
|
||||
dispatch(new HostingAccountCreatedAdminNotificationJob(array_merge($shared, [
|
||||
|
||||
@@ -153,6 +153,10 @@ final class EmailScenarioRegistry
|
||||
'label' => '[Admin] Đơn hàng mới', 'category' => self::INTERNAL, 'group' => 'ORDER',
|
||||
'required_variables' => ['order_num'],
|
||||
],
|
||||
'admin_payment_received' => [
|
||||
'label' => '[Admin] Khách hàng thanh toán', 'category' => self::INTERNAL, 'group' => 'BILLING',
|
||||
'required_variables' => ['reference', 'amount_formatted'],
|
||||
],
|
||||
'admin_domain_order_new' => [
|
||||
'label' => '[Admin] Đơn đăng ký tên miền mới', 'category' => self::INTERNAL, 'group' => 'ORDER',
|
||||
'required_variables' => ['domain_name', 'order_num'],
|
||||
|
||||
@@ -49,6 +49,19 @@ final class MailSettings
|
||||
return self::resolve()['transport'] ?? null;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array{cc: array<int, string>, bcc: array<int, string>}
|
||||
*/
|
||||
public static function recipients(): array
|
||||
{
|
||||
$settings = self::resolve();
|
||||
|
||||
return [
|
||||
'cc' => $settings['cc'] ?? [],
|
||||
'bcc' => $settings['bcc'] ?? [],
|
||||
];
|
||||
}
|
||||
|
||||
public static function refresh(object $mailer): void
|
||||
{
|
||||
self::apply();
|
||||
@@ -91,6 +104,8 @@ final class MailSettings
|
||||
'transport' => $transport,
|
||||
'from_address' => ($mail->from_address ?? null) ?: ($mail->user ?? null),
|
||||
'from_name' => ($mail->from_name ?? null) ?: $company,
|
||||
'cc' => self::emailList($mail->cc ?? []),
|
||||
'bcc' => self::emailList($mail->bcc ?? []),
|
||||
'mailer' => array_filter([
|
||||
'transport' => $transport,
|
||||
'host' => $mail->host ?? '',
|
||||
@@ -102,4 +117,17 @@ final class MailSettings
|
||||
];
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<int, string>
|
||||
*/
|
||||
private static function emailList(mixed $value): array
|
||||
{
|
||||
$emails = is_array($value) ? $value : preg_split('/[,;\s]+/', (string) $value, -1, PREG_SPLIT_NO_EMPTY);
|
||||
|
||||
return array_values(array_unique(array_filter(
|
||||
array_map(fn ($email) => strtolower(trim((string) $email)), $emails ?: []),
|
||||
fn (string $email) => filter_var($email, FILTER_VALIDATE_EMAIL) !== false,
|
||||
)));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
public function up(): void
|
||||
{
|
||||
Schema::table('dns_manager_servers', function (Blueprint $table) {
|
||||
$table->string('domain')->nullable()->after('ip_address');
|
||||
});
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
Schema::table('dns_manager_servers', function (Blueprint $table) {
|
||||
$table->dropColumn('domain');
|
||||
});
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,50 @@
|
||||
<?php
|
||||
|
||||
use App\Support\EmailTemplateShell;
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
private const SCENARIO = 'admin_payment_received';
|
||||
|
||||
public function up(): void
|
||||
{
|
||||
if (DB::table('template')->where('scenario', self::SCENARIO)->exists()) {
|
||||
return;
|
||||
}
|
||||
|
||||
$body = EmailTemplateShell::title('Khách hàng vừa thanh toán')
|
||||
. EmailTemplateShell::paragraph('Hệ thống <strong>{{company_name}}</strong> vừa ghi nhận một khoản thanh toán mới.')
|
||||
. EmailTemplateShell::infoTable([
|
||||
'Loại thanh toán' => '{{payment_type}}',
|
||||
'Mã tham chiếu' => '#{{reference}}',
|
||||
'Số hóa đơn' => '{{invoice_number}}',
|
||||
'Khách hàng' => '{{customer_name}}',
|
||||
'Email' => '{{customer_email}}',
|
||||
'Số tiền vừa trả' => '{{amount_formatted}}',
|
||||
'Còn thiếu' => '{{outstanding_formatted}}',
|
||||
'Trạng thái' => '{{payment_status}}',
|
||||
'Thời gian' => '{{created_at}}',
|
||||
]);
|
||||
|
||||
DB::table('template')->insert([
|
||||
'type_id' => 0,
|
||||
'scenario' => self::SCENARIO,
|
||||
'key' => self::SCENARIO,
|
||||
'format' => 'html',
|
||||
'name' => '[Admin] Khách hàng thanh toán',
|
||||
'subject' => '[ADMIN] Khách hàng thanh toán {{payment_type}} #{{reference}}',
|
||||
'message' => EmailTemplateShell::wrap(EmailTemplateShell::body($body)),
|
||||
'metadata' => json_encode(['variables' => []], JSON_UNESCAPED_UNICODE),
|
||||
'status' => 1,
|
||||
'created_at' => now(),
|
||||
'updated_at' => now(),
|
||||
]);
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
DB::table('template')->where('scenario', self::SCENARIO)->delete();
|
||||
}
|
||||
};
|
||||
@@ -12,6 +12,24 @@ final class AdminTemplates
|
||||
public static function all(): array
|
||||
{
|
||||
return [
|
||||
[
|
||||
'scenario' => 'admin_payment_received',
|
||||
'name' => '[Admin] Khách hàng thanh toán',
|
||||
'subject' => '[ADMIN] Khách hàng thanh toán {{payment_type}} #{{reference}}',
|
||||
'body' => T::title('Khách hàng vừa thanh toán')
|
||||
. T::paragraph('Hệ thống <strong>{{company_name}}</strong> vừa ghi nhận một khoản thanh toán mới.')
|
||||
. T::infoTable([
|
||||
'Loại thanh toán' => '{{payment_type}}',
|
||||
'Mã tham chiếu' => '#{{reference}}',
|
||||
'Số hóa đơn' => '{{invoice_number}}',
|
||||
'Khách hàng' => '{{customer_name}}',
|
||||
'Email' => '{{customer_email}}',
|
||||
'Số tiền vừa trả' => '{{amount_formatted}}',
|
||||
'Còn thiếu' => '{{outstanding_formatted}}',
|
||||
'Trạng thái' => '{{payment_status}}',
|
||||
'Thời gian' => '{{created_at}}',
|
||||
]),
|
||||
],
|
||||
[
|
||||
'scenario' => 'admin_order_notification',
|
||||
'name' => '[Admin] Đơn hàng mới',
|
||||
|
||||
@@ -103,6 +103,10 @@ final class TemplateVariables
|
||||
'paid_row' => ['[Tự động] Dòng đã thanh toán', 'html', ''],
|
||||
'transaction_code' => ['Mã giao dịch', 'string', 'NAP20261008001'],
|
||||
'amount_formatted' => ['Số tiền', 'string', '500,000'],
|
||||
'payment_type' => ['Loại thanh toán', 'string', 'Hóa đơn'],
|
||||
'reference' => ['Mã tham chiếu', 'string', 'DH20261017001'],
|
||||
'outstanding_formatted' => ['Số tiền còn thiếu', 'string', '0 VNĐ'],
|
||||
'payment_status' => ['Trạng thái thanh toán', 'string', 'Đã thanh toán đủ'],
|
||||
'credit_formatted' => ['Tiền khuyến mãi', 'string', '0'],
|
||||
'current_total_formatted' => ['Số dư hiện tại', 'string', '1,500,000'],
|
||||
'current_credit_formatted' => ['Số dư khuyến mãi hiện tại', 'string', '0'],
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Tests\Feature\Api\Admin\DnsManager;
|
||||
|
||||
use App\Models\DnsServer;
|
||||
use App\Models\User;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Spatie\Permission\Models\Role;
|
||||
use Tests\TestCase;
|
||||
|
||||
class DnsServerDomainTest extends TestCase
|
||||
{
|
||||
use RefreshDatabase;
|
||||
|
||||
private function admin(): User
|
||||
{
|
||||
Role::firstOrCreate(['name' => 'Full Administrator', 'guard_name' => 'admin']);
|
||||
$admin = User::factory()->create();
|
||||
$admin->assignRole('Full Administrator');
|
||||
|
||||
return $admin;
|
||||
}
|
||||
|
||||
public function test_public_host_prefers_domain_over_ip(): void
|
||||
{
|
||||
$server = new DnsServer(['ip_address' => '103.42.58.99', 'domain' => 'host99.tgs.com.vn']);
|
||||
$this->assertSame('host99.tgs.com.vn', $server->publicHost());
|
||||
|
||||
$server->domain = null;
|
||||
$this->assertSame('103.42.58.99', $server->publicHost());
|
||||
}
|
||||
|
||||
public function test_store_saves_domain_and_rejects_invalid_hostname(): void
|
||||
{
|
||||
$admin = $this->admin();
|
||||
$payload = ['name' => 'Host 99', 'ip_address' => '103.42.58.99', 'username' => 'root', 'password' => 'secret'];
|
||||
|
||||
$this->actingAs($admin, 'admin')
|
||||
->postJson('/api/v1/administrator/dns-servers', $payload + ['domain' => 'not a domain'])
|
||||
->assertStatus(422);
|
||||
|
||||
$this->actingAs($admin, 'admin')
|
||||
->postJson('/api/v1/administrator/dns-servers', $payload + ['domain' => 'host99.tgs.com.vn'])
|
||||
->assertStatus(201)
|
||||
->assertJsonPath('data.domain', 'host99.tgs.com.vn');
|
||||
|
||||
$this->assertDatabaseHas('dns_manager_servers', ['ip_address' => '103.42.58.99', 'domain' => 'host99.tgs.com.vn']);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Tests\Feature\Api\Service;
|
||||
|
||||
use App\Enums\Invoice\InvoiceStatus;
|
||||
use App\Enums\Order\OrderStatus;
|
||||
use App\Jobs\Mail\Admin\AdminPaymentReceivedJob;
|
||||
use App\Models\Customer;
|
||||
use App\Models\Invoice;
|
||||
use App\Models\Order;
|
||||
use App\Services\Api\Order\OrderService;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Illuminate\Support\Facades\Bus;
|
||||
use Tests\TestCase;
|
||||
|
||||
class OrderPaymentAdminNotificationTest extends TestCase
|
||||
{
|
||||
use RefreshDatabase;
|
||||
|
||||
private function orderFor(Customer $customer, int $invoiceStatus): Order
|
||||
{
|
||||
$order = Order::create([
|
||||
'order_num' => 'ORD-' . uniqid(),
|
||||
'customer_id' => $customer->id,
|
||||
'sub_total' => 100_000,
|
||||
'discount_amount' => 0,
|
||||
'total' => 100_000,
|
||||
'status' => OrderStatus::PENDING->value,
|
||||
]);
|
||||
|
||||
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' => $invoiceStatus,
|
||||
]);
|
||||
|
||||
return $order->fresh(['invoice', 'customer']);
|
||||
}
|
||||
|
||||
public function test_successful_payment_notifies_admin_once(): void
|
||||
{
|
||||
Bus::fake([AdminPaymentReceivedJob::class]);
|
||||
$customer = Customer::factory()->create(['total' => 200_000]);
|
||||
$order = $this->orderFor($customer, InvoiceStatus::UNPAID->value);
|
||||
|
||||
app(OrderService::class)->pay($order, 'balance');
|
||||
|
||||
Bus::assertDispatchedTimes(AdminPaymentReceivedJob::class, 1);
|
||||
}
|
||||
|
||||
public function test_insufficient_balance_does_not_notify_admin(): void
|
||||
{
|
||||
Bus::fake([AdminPaymentReceivedJob::class]);
|
||||
$customer = Customer::factory()->create(['total' => 0]);
|
||||
$order = $this->orderFor($customer, InvoiceStatus::UNPAID->value);
|
||||
|
||||
try {
|
||||
app(OrderService::class)->pay($order, 'balance');
|
||||
$this->fail('Expected InvalidArgumentException');
|
||||
} catch (\InvalidArgumentException) {
|
||||
}
|
||||
|
||||
Bus::assertNotDispatched(AdminPaymentReceivedJob::class);
|
||||
}
|
||||
|
||||
public function test_paying_an_already_paid_invoice_is_rejected_without_notifying_admin(): void
|
||||
{
|
||||
Bus::fake([AdminPaymentReceivedJob::class]);
|
||||
$customer = Customer::factory()->create(['total' => 200_000]);
|
||||
$order = $this->orderFor($customer, InvoiceStatus::PAID->value);
|
||||
|
||||
try {
|
||||
app(OrderService::class)->pay($order, 'balance');
|
||||
$this->fail('Expected InvalidArgumentException');
|
||||
} catch (\InvalidArgumentException) {
|
||||
}
|
||||
|
||||
Bus::assertNotDispatched(AdminPaymentReceivedJob::class);
|
||||
$this->assertSame(200_000, (int) $customer->fresh()->total);
|
||||
}
|
||||
}
|
||||
@@ -5,12 +5,17 @@ declare(strict_types=1);
|
||||
namespace Tests\Feature\Api\Transaction;
|
||||
|
||||
use App\Enums\TransactionStatus;
|
||||
use App\Events\AdminNotificationCreated;
|
||||
use App\Models\Customer;
|
||||
use App\Models\Transaction;
|
||||
use App\Models\User;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Illuminate\Http\UploadedFile;
|
||||
use Illuminate\Support\Facades\Event;
|
||||
use Illuminate\Support\Facades\Mail;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
use Illuminate\Support\Str;
|
||||
use Spatie\Permission\Models\Role;
|
||||
use Tests\TestCase;
|
||||
|
||||
class ManualDepositProofTest extends TestCase
|
||||
@@ -108,8 +113,13 @@ class ManualDepositProofTest extends TestCase
|
||||
public function test_upload_then_confirm_binds_proof_to_owner(): void
|
||||
{
|
||||
Storage::fake('local');
|
||||
Mail::fake();
|
||||
Event::fake([AdminNotificationCreated::class]);
|
||||
|
||||
$customer = Customer::factory()->create();
|
||||
Role::firstOrCreate(['name' => 'Full Administrator', 'guard_name' => 'admin']);
|
||||
$admin = User::factory()->create();
|
||||
$admin->assignRole('Full Administrator');
|
||||
|
||||
$transaction = Transaction::create([
|
||||
'uuid' => Str::uuid()->toString(),
|
||||
@@ -138,5 +148,15 @@ class ManualDepositProofTest extends TestCase
|
||||
|
||||
$confirm->assertStatus(200)->assertJsonPath('error', false);
|
||||
$this->assertSame($path, $transaction->fresh()->proof_image);
|
||||
$this->assertDatabaseHas('notifications', [
|
||||
'recipient_type' => 'admin',
|
||||
'recipient_id' => $admin->id,
|
||||
'type' => 'manual_deposit_pending',
|
||||
'link' => '/transactions',
|
||||
]);
|
||||
Event::assertDispatched(
|
||||
AdminNotificationCreated::class,
|
||||
fn (AdminNotificationCreated $event) => $event->type === 'manual_deposit_pending',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,8 +7,11 @@ namespace Tests\Feature\Mail;
|
||||
use App\Http\Facade\SignRSA;
|
||||
use App\Models\Setting;
|
||||
use App\Support\MailSettings;
|
||||
use App\Listeners\Mail\ApplySystemRecipients;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Illuminate\Mail\Events\MessageSending;
|
||||
use Illuminate\Support\Facades\Cache;
|
||||
use Symfony\Component\Mime\Email;
|
||||
use Tests\TestCase;
|
||||
|
||||
class MailSettingsQueueTest extends TestCase
|
||||
@@ -45,4 +48,21 @@ class MailSettingsQueueTest extends TestCase
|
||||
$this->saveSmtp('smtp.settings-two.test');
|
||||
$this->assertSame('smtp.settings-two.test', $this->hostSeenByQueuedJob());
|
||||
}
|
||||
|
||||
public function test_system_cc_and_bcc_are_applied_without_duplicate_recipients(): void
|
||||
{
|
||||
$this->saveSmtp('smtp.settings.test');
|
||||
$smtp = Setting::where('key', 'smtp')->firstOrFail();
|
||||
$values = json_decode((string) $smtp->value, true);
|
||||
$values['cc'] = ['audit@example.com', 'customer@example.com'];
|
||||
$values['bcc'] = ['archive@example.com', 'audit@example.com'];
|
||||
$smtp->update(['value' => json_encode($values)]);
|
||||
Cache::forget(MailSettings::cacheKey());
|
||||
|
||||
$message = (new Email())->to('customer@example.com')->subject('Test');
|
||||
app(ApplySystemRecipients::class)->handle(new MessageSending($message, []));
|
||||
|
||||
$this->assertSame(['audit@example.com'], array_map(fn ($address) => $address->getAddress(), $message->getCc()));
|
||||
$this->assertSame(['archive@example.com'], array_map(fn ($address) => $address->getAddress(), $message->getBcc()));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Tests\Unit;
|
||||
|
||||
use App\Enums\Product\ProductBillingCycle;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
|
||||
class ProductBillingCycleLabelTest extends TestCase
|
||||
{
|
||||
public function test_code_is_translated_to_vietnamese_label(): void
|
||||
{
|
||||
$this->assertSame('Hàng tháng', ProductBillingCycle::vietnameseLabelFor('monthly'));
|
||||
$this->assertSame('6 tháng', ProductBillingCycle::vietnameseLabelFor('semi_annually'));
|
||||
$this->assertSame('Hàng năm', ProductBillingCycle::vietnameseLabelFor('annually'));
|
||||
}
|
||||
|
||||
public function test_unknown_or_missing_code_falls_back_safely(): void
|
||||
{
|
||||
$this->assertSame('custom', ProductBillingCycle::vietnameseLabelFor('custom'));
|
||||
$this->assertSame('N/A', ProductBillingCycle::vietnameseLabelFor(null));
|
||||
}
|
||||
|
||||
public function test_months_are_translated_with_fallback(): void
|
||||
{
|
||||
$this->assertSame('Hàng tháng', ProductBillingCycle::vietnameseLabelForMonths(1));
|
||||
$this->assertSame('2 năm', ProductBillingCycle::vietnameseLabelForMonths(24));
|
||||
$this->assertSame('9 tháng', ProductBillingCycle::vietnameseLabelForMonths(9));
|
||||
$this->assertSame('0 tháng', ProductBillingCycle::vietnameseLabelForMonths(0));
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user