From c5115b52dd1babb7804675a62f058ff1db849ea0 Mon Sep 17 00:00:00 2001 From: baodev0 Date: Sun, 11 Oct 2026 23:07:51 +0700 Subject: [PATCH] =?UTF-8?q?feat:=20n=E1=BA=A1p=20ti=E1=BB=81n=20th?= =?UTF-8?q?=E1=BB=A7=20c=C3=B4ng=20m=C3=A3=2030=20ph=C3=BAt,=20gia=20h?= =?UTF-8?q?=E1=BA=A1n=20tr=E1=BB=AB=20s=E1=BB=91=20d=C6=B0,=20=C4=91?= =?UTF-8?q?=E1=BB=93ng=20b=E1=BB=99=20domain=20t=E1=BB=AB=20Plesk?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Nạp tiền: mã thanh toán sống 30 phút (ManualDepositDraft), khôi phục/huỷ mã, lệnh dọn mã hết hạn; total_pending chỉ tính giao dịch đã nộp chứng từ, admin duyệt/từ chối/xoá giao dịch - Gia hạn dịch vụ: phát hành invoice gia hạn trên order gốc và trừ số dư ngay - Đồng bộ domain từ Plesk theo server, ServiceOrderRecorder tạo order/invoice cho dịch vụ admin dựng - Danh sách dịch vụ trả field theo loại (service_plan, dns_records_count), đếm dịch vụ theo loại Co-Authored-By: Claude Opus 5.5 --- .../Commands/PurgeExpiredManualDeposits.php | 30 ++ app/Console/Kernel.php | 4 + app/Events/DnsSyncCompleted.php | 6 +- .../Concerns/ManagesCustomerDomains.php | 85 +++++ .../Api/Admin/DnsManager/DomainController.php | 26 +- .../Domain/AdminDomainOrderController.php | 1 + .../Product/ProductCategoryController.php | 12 +- .../Api/Admin/Service/ServiceController.php | 30 ++ .../AdminTransactionController.php | 58 ++- .../Api/Client/ClientController.php | 2 +- .../Concerns/HandlesClientAuthentication.php | 6 +- .../Client/Concerns/ManagesClientProfile.php | 4 +- .../Api/Client/Service/ServiceController.php | 18 + .../Api/Service/OrderControllerRefactor.php | 7 +- .../Api/Service/ProductCategoryController.php | 84 +---- .../Api/Service/ServiceController.php | 3 +- .../Transaction/ManualDepositController.php | 101 +++--- .../Api/Transaction/TransactionController.php | 39 +- .../Requests/Api/Order/StoreOrderRequest.php | 23 +- .../Resources/Api/Client/ClientResource.php | 6 +- .../Service/ServiceClientDetailResource.php | 10 +- .../Resources/Api/Service/ServiceResource.php | 3 + .../Api/Service/VpsServiceResource.php | 29 +- app/Jobs/DnsManager/SyncDnsFromPlesk.php | 94 ++++- app/Models/DomainPricing.php | 16 +- app/Models/Product.php | 5 + app/Models/ServicePlan.php | 17 + app/Models/Transaction.php | 14 + app/Providers/RouteServiceProvider.php | 10 +- .../ServiceClient/ServiceClientRepository.php | 8 +- .../ServiceClientRepositoryInterface.php | 2 +- app/Services/Api/Order/OrderQueryService.php | 173 +++++---- .../ServiceClient/ServiceClientService.php | 44 ++- .../Customer/CustomerImpersonationService.php | 2 +- .../CustomerServiceProvisioningService.php | 95 ++--- .../Domain/CustomerDomainServiceSync.php | 116 ++++++ app/Services/Order/ServiceOrderRecorder.php | 171 +++++++++ app/Services/Plesk/PleskDomainService.php | 25 ++ app/Support/ManualDepositDraft.php | 46 +++ ...11_000002_backfill_dns_domain_services.php | 69 ++++ ...003_recalculate_customer_total_pending.php | 25 ++ routes/api/admin/billing.php | 1 + routes/api/admin/customers.php | 4 + routes/client.php | 3 + .../CreateDomainServicePricingTest.php | 105 ++++++ .../Admin/CustomerDomainServiceSyncTest.php | 72 ++++ .../DnsManager/SyncDomainsFromPleskTest.php | 103 ++++++ .../Product/ProductCategorySortOrderTest.php | 32 ++ .../Api/Client/ClientServiceListTest.php | 94 +++++ .../Feature/Api/Service/RenewServiceTest.php | 340 +++--------------- .../ManualDepositDraftExpiryTest.php | 131 +++++++ .../Payment/AdminManualDepositActionsTest.php | 93 +++++ .../Payment/AdminTransactionApproveTest.php | 3 + 53 files changed, 1887 insertions(+), 613 deletions(-) create mode 100644 app/Console/Commands/PurgeExpiredManualDeposits.php create mode 100644 app/Services/Domain/CustomerDomainServiceSync.php create mode 100644 app/Services/Order/ServiceOrderRecorder.php create mode 100644 app/Support/ManualDepositDraft.php create mode 100644 database/migrations/2026_10_11_000002_backfill_dns_domain_services.php create mode 100644 database/migrations/2026_10_11_000003_recalculate_customer_total_pending.php create mode 100644 tests/Feature/Api/Admin/Customer/CreateDomainServicePricingTest.php create mode 100644 tests/Feature/Api/Admin/CustomerDomainServiceSyncTest.php create mode 100644 tests/Feature/Api/Admin/DnsManager/SyncDomainsFromPleskTest.php create mode 100644 tests/Feature/Api/Admin/Product/ProductCategorySortOrderTest.php create mode 100644 tests/Feature/Api/Client/ClientServiceListTest.php create mode 100644 tests/Feature/Api/Transaction/ManualDepositDraftExpiryTest.php create mode 100644 tests/Feature/Payment/AdminManualDepositActionsTest.php diff --git a/app/Console/Commands/PurgeExpiredManualDeposits.php b/app/Console/Commands/PurgeExpiredManualDeposits.php new file mode 100644 index 0000000..2f2b232 --- /dev/null +++ b/app/Console/Commands/PurgeExpiredManualDeposits.php @@ -0,0 +1,30 @@ +chunkById(200, function ($drafts) use (&$purged) { + $drafts->each(function (Transaction $draft) use (&$purged) { + if (ManualDepositDraft::discard($draft)) { + $purged++; + } + }); + }); + + $this->info("deposits:purge-expired-drafts | Purged {$purged} draft(s)."); + + return self::SUCCESS; + } +} diff --git a/app/Console/Kernel.php b/app/Console/Kernel.php index 61078c0..ca7b9fc 100644 --- a/app/Console/Kernel.php +++ b/app/Console/Kernel.php @@ -45,6 +45,10 @@ class Kernel extends ConsoleKernel ->everyFiveMinutes() ->withoutOverlapping(); + $schedule->command('deposits:purge-expired-drafts') + ->everyFiveMinutes() + ->withoutOverlapping(); + /* Sync all VM packages */ $schedule->job(new SyncAllPackage()) ->everyThreeHours() diff --git a/app/Events/DnsSyncCompleted.php b/app/Events/DnsSyncCompleted.php index b4cc822..f1ba4fd 100644 --- a/app/Events/DnsSyncCompleted.php +++ b/app/Events/DnsSyncCompleted.php @@ -15,6 +15,7 @@ class DnsSyncCompleted implements ShouldBroadcastNow public function __construct( public readonly int $synced, public readonly array $failedDomains, + public readonly int $imported = 0, ) {} public function broadcastOn(): array @@ -32,7 +33,10 @@ class DnsSyncCompleted implements ShouldBroadcastNow return [ 'synced' => $this->synced, 'failed_domains' => $this->failedDomains, - 'message' => "Đã đồng bộ {$this->synced} bản ghi DNS từ Plesk", + 'imported' => $this->imported, + 'message' => $this->imported > 0 + ? "Đã nhập {$this->imported} tên miền và đồng bộ {$this->synced} bản ghi DNS từ Plesk" + : "Đã đồng bộ {$this->synced} bản ghi DNS từ Plesk", ]; } } diff --git a/app/Http/Controllers/Api/Admin/Customer/Concerns/ManagesCustomerDomains.php b/app/Http/Controllers/Api/Admin/Customer/Concerns/ManagesCustomerDomains.php index 77a6a02..34037de 100644 --- a/app/Http/Controllers/Api/Admin/Customer/Concerns/ManagesCustomerDomains.php +++ b/app/Http/Controllers/Api/Admin/Customer/Concerns/ManagesCustomerDomains.php @@ -4,7 +4,11 @@ namespace App\Http\Controllers\Api\Admin\Customer\Concerns; use App\Models\DnsDomain; use App\Models\DnsRecord; +use App\Models\Service; +use App\Services\Domain\CustomerDomainServiceSync; use Illuminate\Http\JsonResponse; +use Illuminate\Support\Collection; +use Illuminate\Http\Request; use Illuminate\Support\Facades\DB; trait ManagesCustomerDomains @@ -21,6 +25,7 @@ trait ManagesCustomerDomains public function getDnsDomains(string $uuid): JsonResponse { $customer = $this->resolveCustomer($uuid); + $domainServices = $this->customerDomainServices($customer->id); $domains = DnsDomain::query() ->with(['server:id,name,ip_address', 'registrar:id,name,short_name,website']) @@ -30,6 +35,7 @@ trait ManagesCustomerDomains ->get() ->map(fn($d) => [ 'id' => $d->id, + 'service_id' => $domainServices->get($this->domainKey($d->domain_name))?->id, 'domain_name' => $d->domain_name, 'is_created' => (int) $d->is_created, 'plesk_id' => $d->plesk_id, @@ -46,6 +52,7 @@ trait ManagesCustomerDomains 'short_name' => $d->registrar->short_name, 'website' => $d->registrar->website, ] : null, + 'expires_at' => $domainServices->get($this->domainKey($d->domain_name))?->expires_at, 'created_at' => $d->created_at, 'updated_at' => $d->updated_at, ]); @@ -53,6 +60,84 @@ trait ManagesCustomerDomains return response()->json(['error' => false, 'data' => $domains]); } + public function updateDnsDomain(string $uuid, int $domainId, Request $request): JsonResponse + { + $domain = $this->resolveCustomerDnsDomain($uuid, $domainId); + + $data = $request->validate([ + 'is_created' => 'required|integer|in:0,1,2,3', + 'created_at' => 'required|date', + 'expires_at' => 'nullable|date', + ]); + + $customer = $this->resolveCustomer($uuid); + + DB::transaction(function () use ($domain, $customer, $data) { + $domain->forceFill(['is_created' => $data['is_created'], 'created_at' => $data['created_at']])->save(); + app(CustomerDomainServiceSync::class)->updateCustomerDomain($domain, $customer->id, $data['expires_at']); + }); + + return response()->json(['error' => false, 'message' => 'Cập nhật domain thành công']); + } + + public function detachDnsDomain(string $uuid, int $domainId): JsonResponse + { + $customer = $this->resolveCustomer($uuid); + $domain = $this->resolveCustomerDnsDomain($uuid, $domainId); + + $remainingCustomerIds = $domain->customer()->pluck('customers.id')->reject( + fn ($customerId) => (int) $customerId === (int) $customer->id, + )->all(); + app(CustomerDomainServiceSync::class)->syncCustomers($domain, $remainingCustomerIds); + + return response()->json(['error' => false, 'message' => 'Đã gỡ domain khỏi khách hàng']); + } + + public function assignDnsDomain(string $uuid, Request $request): JsonResponse + { + $customer = $this->resolveCustomer($uuid); + $data = $request->validate([ + 'domain_id' => ['required', 'integer', 'exists:dns_manager_domain,id'], + 'service_id' => ['required', 'integer', 'exists:services,id'], + ]); + + abort_unless( + Service::query()->whereKey($data['service_id'])->where('customer_id', $customer->id)->exists(), + 422, + 'Dịch vụ không thuộc khách hàng này', + ); + + $domain = DnsDomain::query()->findOrFail($data['domain_id']); + $customerIds = $domain->customer()->pluck('customers.id')->push($customer->id)->all(); + app(CustomerDomainServiceSync::class)->syncCustomers($domain, $customerIds); + + return response()->json(['error' => false, 'message' => 'Gán domain thành công']); + } + + private function customerDomainServices(int $customerId): Collection + { + return Service::query() + ->where('customer_id', $customerId) + ->where('type', 'domain') + ->orderBy('id') + ->get() + ->keyBy(fn(Service $service) => $this->domainKey($service->server_name)); + } + + private function domainKey(?string $domainName): string + { + return mb_strtolower(trim((string) $domainName)); + } + + private function resolveCustomerDnsDomain(string $uuid, int $domainId): DnsDomain + { + $customer = $this->resolveCustomer($uuid); + + return DnsDomain::query() + ->whereHas('customer', fn($q) => $q->where('customers.id', $customer->id)) + ->findOrFail($domainId); + } + public function getDnsDomainRecords(string $uuid, int $domainId): JsonResponse { $customer = $this->resolveCustomer($uuid); diff --git a/app/Http/Controllers/Api/Admin/DnsManager/DomainController.php b/app/Http/Controllers/Api/Admin/DnsManager/DomainController.php index 8232f80..cba4770 100644 --- a/app/Http/Controllers/Api/Admin/DnsManager/DomainController.php +++ b/app/Http/Controllers/Api/Admin/DnsManager/DomainController.php @@ -10,6 +10,7 @@ use App\Jobs\DnsManager\SyncDnsFromPlesk; use App\Repositories\DnsManager\Domain\DnsDomainRepositoryInterface; use App\Repositories\DnsManager\Record\DnsRecordRepositoryInterface; use App\Services\ServerService; +use App\Services\Domain\CustomerDomainServiceSync; use Illuminate\Http\JsonResponse; use Illuminate\Http\Request; use Illuminate\Support\Facades\DB; @@ -32,6 +33,7 @@ class DomainController extends Controller private readonly DnsDomainRepositoryInterface $domainRepository, private readonly DnsRecordRepositoryInterface $recordRepository, private readonly ServerService $serverService, + private readonly CustomerDomainServiceSync $customerDomainServiceSync, ) {} // ─── Domains ───────────────────────────────────────────────────────────── @@ -114,7 +116,7 @@ class DomainController extends Controller ]); if (!empty($request->customer_ids)) { - $domain->customer()->sync($request->customer_ids); + $this->customerDomainServiceSync->syncCustomers($domain, $request->customer_ids); } DB::commit(); @@ -153,6 +155,7 @@ class DomainController extends Controller DB::beginTransaction(); try { + $previousDomainName = $domain->domain_name; $this->domainRepository->update($id, $request->only([ 'domain_name', 'dns_manager_server_id', @@ -161,8 +164,19 @@ class DomainController extends Controller ])); // Sync customers (empty array = detach all) + $freshDomain = $domain->fresh(); if ($request->has('customer_ids')) { - $domain->customer()->sync($request->customer_ids ?? []); + $this->customerDomainServiceSync->syncCustomers( + $freshDomain, + $request->customer_ids ?? [], + $previousDomainName, + ); + } elseif ($previousDomainName !== $freshDomain->domain_name) { + $this->customerDomainServiceSync->syncCustomers( + $freshDomain, + $freshDomain->customer()->pluck('customers.id')->all(), + $previousDomainName, + ); } DB::commit(); @@ -270,9 +284,13 @@ class DomainController extends Controller // ─── Sync from Plesk ───────────────────────────────────────────────────── - public function syncDns(): JsonResponse + public function syncDns(Request $request): JsonResponse { - dispatch(new SyncDnsFromPlesk()); + $validated = $request->validate([ + 'dns_manager_server_id' => ['nullable', 'integer', 'exists:dns_manager_servers,id'], + ]); + + dispatch(new SyncDnsFromPlesk($validated['dns_manager_server_id'] ?? null)); return response()->json([ 'error' => false, diff --git a/app/Http/Controllers/Api/Admin/Domain/AdminDomainOrderController.php b/app/Http/Controllers/Api/Admin/Domain/AdminDomainOrderController.php index df2363d..970f586 100644 --- a/app/Http/Controllers/Api/Admin/Domain/AdminDomainOrderController.php +++ b/app/Http/Controllers/Api/Admin/Domain/AdminDomainOrderController.php @@ -373,6 +373,7 @@ class AdminDomainOrderController extends Controller 'refunded_at' => $service->refunded_at?->toIso8601String(), 'cancelled_at' => $service->cancelled_at?->toIso8601String(), 'created_at' => $service->created_at?->toIso8601String(), + 'updated_at' => $service->updated_at?->toIso8601String(), ]; } } diff --git a/app/Http/Controllers/Api/Admin/Product/ProductCategoryController.php b/app/Http/Controllers/Api/Admin/Product/ProductCategoryController.php index 6224cfe..a8fe787 100644 --- a/app/Http/Controllers/Api/Admin/Product/ProductCategoryController.php +++ b/app/Http/Controllers/Api/Admin/Product/ProductCategoryController.php @@ -84,8 +84,12 @@ class ProductCategoryController extends Controller 'name' => 'required|string|max:255', 'parent_id' => 'nullable|integer|exists:product_categories,id', 'is_active' => 'boolean', - 'sort_order' => 'nullable|integer', + 'sort_order' => 'required|integer|min:0', 'type' => 'nullable|string', + ], [ + 'sort_order.required' => 'Vui lòng nhập thứ tự hiển thị (sort_order).', + 'sort_order.integer' => 'Thứ tự hiển thị phải là số nguyên.', + 'sort_order.min' => 'Thứ tự hiển thị không được âm.', ]); $validated['slug'] = Str::slug($validated['name']); @@ -119,8 +123,12 @@ class ProductCategoryController extends Controller 'name' => 'sometimes|required|string|max:255', 'parent_id' => 'nullable|integer|exists:product_categories,id', 'is_active' => 'boolean', - 'sort_order' => 'nullable|integer', + 'sort_order' => 'sometimes|required|integer|min:0', 'type' => 'nullable|string', + ], [ + 'sort_order.required' => 'Vui lòng nhập thứ tự hiển thị (sort_order).', + 'sort_order.integer' => 'Thứ tự hiển thị phải là số nguyên.', + 'sort_order.min' => 'Thứ tự hiển thị không được âm.', ]); if (isset($validated['name']) && $validated['name'] !== $category->name) { diff --git a/app/Http/Controllers/Api/Admin/Service/ServiceController.php b/app/Http/Controllers/Api/Admin/Service/ServiceController.php index 2931211..1c7761b 100644 --- a/app/Http/Controllers/Api/Admin/Service/ServiceController.php +++ b/app/Http/Controllers/Api/Admin/Service/ServiceController.php @@ -2,8 +2,10 @@ namespace App\Http\Controllers\Api\Admin\Service; +use App\Helpers\OrderHelper; use App\Http\Controllers\Controller; use App\Jobs\VirtualMachine\Provision\ResendVmCredentialsEmailJob; +use App\Models\DomainPricing; use App\Models\ModuleVmware; use App\Models\Service; use Illuminate\Http\JsonResponse; @@ -16,6 +18,7 @@ class ServiceController extends Controller $perPage = min((int) ($request->per_page ?? 15), 100); $query = Service::with(['customer:id,uuid,fullname,email', 'product:id,uuid,name', 'invoice:id,invoice_number', 'vmware:id,name,module_ip_address']) + ->where(fn($q) => $q->whereNull('order_item_id')->orWhereNull('type')->orWhere('type', '!=', 'domain')) ->when($request->filter, fn($q, $v) => $q->where('server_name', 'like', "%{$v}%")) ->when($request->status, fn($q, $v) => $q->where('status', $v)) ->when($request->customer_id, fn($q, $v) => $q->where('customer_id', $v)); @@ -34,6 +37,33 @@ class ServiceController extends Controller ]); } + public function domainQuote(Request $request): JsonResponse + { + $data = $request->validate([ + 'domain' => 'required|string|max:255', + 'billing_cycle' => 'nullable|in:monthly,quarterly,semi_annually,annually,biennially,triennially', + ]); + + $pricing = DomainPricing::matchDomain($data['domain']); + if (!$pricing) { + return response()->json(['error' => false, 'data' => null]); + } + + $years = DomainPricing::yearsForMonths(OrderHelper::cycleToMonths($data['billing_cycle'] ?? 'annually')); + + return response()->json([ + 'error' => false, + 'data' => [ + 'tld' => $pricing->tld, + 'setup_fee' => is_numeric($pricing->setup_fee) ? (int) $pricing->setup_fee : 0, + 'first_year' => (int) $pricing->first_year, + 'renewal' => (int) $pricing->recurring_fee, + 'years' => $years, + 'price' => $pricing->orderPrice($years), + ], + ]); + } + public function show(int $id): JsonResponse { $service = Service::with(['customer', 'product', 'orderItem', 'invoice', 'vmware.os.osGroup', 'addons', ...Service::HOSTING_ACCOUNT_RELATIONS]) diff --git a/app/Http/Controllers/Api/Admin/Transaction/AdminTransactionController.php b/app/Http/Controllers/Api/Admin/Transaction/AdminTransactionController.php index 306c34a..8e3c4f2 100644 --- a/app/Http/Controllers/Api/Admin/Transaction/AdminTransactionController.php +++ b/app/Http/Controllers/Api/Admin/Transaction/AdminTransactionController.php @@ -137,8 +137,7 @@ class AdminTransactionController extends Controller $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); + $this->releasePending($customer, $transaction, $declared); $customer->save(); Log::create([ @@ -158,7 +157,7 @@ class AdminTransactionController extends Controller 'type' => 'deposit_success', 'title' => 'Nạp tiền thành công', 'body' => 'Giao dịch ' . $transaction->public_code . ' đã được duyệt.', - 'link' => '/lich-su-giao-dich/' . $transaction->id, + 'link' => '/lich-su-giao-dich/' . $transaction->uuid, 'data' => [ 'transaction_uuid' => $transaction->uuid, 'amount' => $received, @@ -231,7 +230,7 @@ class AdminTransactionController extends Controller $transaction->update(['status' => TransactionStatus::FAILED->value]); $customer = Customer::where('id', $transaction->customer_id)->lockForUpdate()->first(); - $customer->total_pending = max(0, $customer->total_pending - $transaction->amount); + $this->releasePending($customer, $transaction, (int) $transaction->amount); $customer->save(); Log::create([ @@ -288,6 +287,57 @@ class AdminTransactionController extends Controller ]); } + public function destroy(string $uuid): JsonResponse + { + $transaction = DB::transaction(function () use ($uuid) { + $transaction = Transaction::where('uuid', $uuid) + ->where('type', 'manual') + ->lockForUpdate() + ->first(); + + if (! $transaction) { + return null; + } + + if ((int) $transaction->status === TransactionStatus::PENDING->value) { + $customer = Customer::where('id', $transaction->customer_id)->lockForUpdate()->first(); + if ($customer) { + $this->releasePending($customer, $transaction, (int) $transaction->amount); + $customer->save(); + } + } + + $transaction->delete(); + + Log::create([ + 'agent' => 'admin', + 'client_ip' => request()->ip(), + 'description' => sprintf( + 'Admin xoá giao dịch nạp tiền thủ công %s: %s VNĐ (%s)', + $transaction->public_code, + number_format($transaction->amount), + TransactionStatus::from((int) $transaction->status)->label(), + ), + 'customer_id' => $transaction->customer_id, + ]); + + return $transaction; + }); + + if (! $transaction) { + return response()->json(['error' => true, 'message' => 'Giao dịch không tồn tại'], 404); + } + + return response()->json(['error' => false, 'message' => 'Đã xoá giao dịch']); + } + + private function releasePending(Customer $customer, Transaction $transaction, int $amount): void + { + if ($transaction->proof_image) { + $customer->total_pending = max(0, $customer->total_pending - $amount); + } + } + public function proofImage(Transaction $transaction, int $index = 0) { abort_unless($transaction->proof_image, 404); diff --git a/app/Http/Controllers/Api/Client/ClientController.php b/app/Http/Controllers/Api/Client/ClientController.php index 6717472..8ed0571 100644 --- a/app/Http/Controllers/Api/Client/ClientController.php +++ b/app/Http/Controllers/Api/Client/ClientController.php @@ -91,7 +91,7 @@ class ClientController extends Controller return response()->json([ 'error' => false, 'data' => [ - 'client' => new ClientResource(auth('client')->user()->loadCount(['vps', 'hosting', 'domain'])), + 'client' => new ClientResource(auth('client')->user()), ], ]); } diff --git a/app/Http/Controllers/Api/Client/Concerns/HandlesClientAuthentication.php b/app/Http/Controllers/Api/Client/Concerns/HandlesClientAuthentication.php index e422dc5..255a6fe 100644 --- a/app/Http/Controllers/Api/Client/Concerns/HandlesClientAuthentication.php +++ b/app/Http/Controllers/Api/Client/Concerns/HandlesClientAuthentication.php @@ -53,7 +53,7 @@ trait HandlesClientAuthentication return response()->json([ 'error' => false, 'data' => [ - 'client' => new ClientResource($client->loadCount(['vps', 'hosting', 'domain'])), + 'client' => new ClientResource($client), 'token' => $result['token'], 'refresh_token' => $result['refresh_token'], 'expires_in' => config('jwt.ttl', 60) * 60, // seconds @@ -136,7 +136,7 @@ trait HandlesClientAuthentication return response()->json([ 'error' => false, 'data' => [ - 'client' => new ClientResource($client->loadCount(['vps', 'hosting', 'domain'])), + 'client' => new ClientResource($client), 'token' => $result['token'], 'refresh_token' => $result['refresh_token'], 'expires_in' => config('jwt.ttl', 60) * 60, @@ -252,7 +252,7 @@ trait HandlesClientAuthentication 'token' => $result['token'], 'refresh_token' => $result['refresh_token'], 'expires_in' => config('jwt.ttl', 60) * 60, - 'client' => new ClientResource($result['client']->loadCount(['vps', 'hosting', 'domain'])), + 'client' => new ClientResource($result['client']), ], ]); } catch (\Illuminate\Validation\ValidationException $e) { diff --git a/app/Http/Controllers/Api/Client/Concerns/ManagesClientProfile.php b/app/Http/Controllers/Api/Client/Concerns/ManagesClientProfile.php index 38b6444..6ca2d01 100644 --- a/app/Http/Controllers/Api/Client/Concerns/ManagesClientProfile.php +++ b/app/Http/Controllers/Api/Client/Concerns/ManagesClientProfile.php @@ -30,7 +30,7 @@ trait ManagesClientProfile 'error' => false, 'message' => 'Cập nhật thông tin thành công', 'data' => [ - 'client' => new ClientResource($user->loadCount(['vps', 'hosting', 'domain'])), + 'client' => new ClientResource($user), ], ]); } @@ -64,7 +64,7 @@ trait ManagesClientProfile 'error' => false, 'message' => 'Cập nhật chứng minh thư thành công', 'data' => [ - 'client' => new ClientResource($user->loadCount(['vps', 'hosting', 'domain'])), + 'client' => new ClientResource($user), ], ]); } diff --git a/app/Http/Controllers/Api/Client/Service/ServiceController.php b/app/Http/Controllers/Api/Client/Service/ServiceController.php index 1925016..b3ea070 100644 --- a/app/Http/Controllers/Api/Client/Service/ServiceController.php +++ b/app/Http/Controllers/Api/Client/Service/ServiceController.php @@ -36,6 +36,22 @@ class ServiceController extends Controller $this->activityLogger = $activityLogger; } + public function index(Request $request): JsonResponse + { + $customer = $request->user(); + + $services = $this->serviceClientService->getActiveServices($customer->id, null, null); + $this->serviceClientService->attachDnsRecordCounts($services, $customer->id); + + return (new VpsServiceCollection($services)) + ->additional([ + 'error' => false, + 'message' => 'Lấy dữ liệu thành công', + 'page_type' => 'services', + ]) + ->response(); + } + public function indexVps(Request $request): JsonResponse { $perPage = min((int) $request->get('per_page', 15), 100); @@ -123,6 +139,7 @@ class ServiceController extends Controller $customerId = (int) $request->user()->id; $services = $this->serviceClientService->getActiveServices($customerId, null, 5); + $this->serviceClientService->attachDnsRecordCounts($services, $customerId); $invoices = $orderQueryService->getCustomerInvoices($customerId)->take(5); return response()->json([ @@ -131,6 +148,7 @@ class ServiceController extends Controller 'data' => [ 'services' => (new VpsServiceCollection($services))->collection, 'invoices' => InvoiceSummaryResource::collection($invoices), + 'counts' => $this->serviceClientService->countByType($customerId), ], ]); } diff --git a/app/Http/Controllers/Api/Service/OrderControllerRefactor.php b/app/Http/Controllers/Api/Service/OrderControllerRefactor.php index 24ea652..db52c6e 100644 --- a/app/Http/Controllers/Api/Service/OrderControllerRefactor.php +++ b/app/Http/Controllers/Api/Service/OrderControllerRefactor.php @@ -337,12 +337,15 @@ class OrderControllerRefactor extends Controller { $result = $this->orderQueryService->renewService( $id, - (int) $request->user()->id, + $request->user(), (int) $request->validated()['months'], ); if (!$result['ok']) { - return response()->json(['success' => false, 'message' => $result['message']], $result['code']); + return response()->json( + ['success' => false, 'message' => $result['message'], 'data' => $result['data'] ?? null], + $result['code'], + ); } return response()->json(['success' => true, 'message' => $result['message'], 'data' => $result['data']]); diff --git a/app/Http/Controllers/Api/Service/ProductCategoryController.php b/app/Http/Controllers/Api/Service/ProductCategoryController.php index 7b02af6..a2c75da 100644 --- a/app/Http/Controllers/Api/Service/ProductCategoryController.php +++ b/app/Http/Controllers/Api/Service/ProductCategoryController.php @@ -5,6 +5,7 @@ namespace App\Http\Controllers\Api\Service; use App\Enums\Product\ProductTypeEnums; use App\Http\Controllers\Controller; use App\Http\Resources\Api\Service\ProductCategoryResource; +use App\Http\Resources\Api\Service\ServiceResource; use App\Models\Product; use App\Models\ProductCategories; use Illuminate\Http\Request; @@ -161,11 +162,12 @@ class ProductCategoryController extends Controller ->with([ 'osVersions.osGroup', 'category', + 'servicePlan', 'pricings' => fn($q) => $q->where('is_active', 1) ]) ->get(); - $productResources = $products->map(fn($product) => $this->formatProduct($product)); + $productResources = ServiceResource::collection($products)->resolve(); $categoryArray = $category->toArray(); $categoryArray['products'] = $productResources; @@ -265,11 +267,12 @@ class ProductCategoryController extends Controller ->with([ 'osVersions.osGroup', 'category', + 'servicePlan', 'pricings' => fn($q) => $q->where('is_active', 1) ]) ->get(); - $productResources = $products->map(fn($product) => $this->formatProduct($product)); + $productResources = ServiceResource::collection($products)->resolve(); $childArray = $child->toArray(); $childArray['parent'] = $child->parent ? $child->parent->toArray() : null; @@ -281,81 +284,4 @@ class ProductCategoryController extends Controller 'data' => $childArray, ]); } - - private function formatProduct($product): array - { - $billingCycleLabels = [ - 'monthly' => 'Hàng tháng', - 'quarterly' => 'Hàng quý', - 'semi_annually' => 'Nửa năm', - 'annually' => 'Hàng năm', - 'biennially' => '2 năm', - 'triennially' => '3 năm', - 'onetime' => 'Một lần', - ]; - - $periodMonths = [ - 'monthly' => 1, - 'quarterly' => 3, - 'semi_annually' => 6, - 'annually' => 12, - 'biennially' => 24, - 'triennially' => 36, - 'onetime' => 0, - ]; - - $periodUnits = [ - 'monthly' => 'month', - 'quarterly' => 'quarter', - 'semi_annually' => 'semi_year', - 'annually' => 'year', - 'biennially' => 'biennially', - 'triennially' => 'triennially', - 'onetime' => 'none', - ]; - - return [ - 'uuid' => $product->uuid, - 'name' => $product->name, - 'slug' => $product->slug, - 'short_description' => $product->short_description, - 'description' => $product->description, - 'thumbnail' => $product->thumbnail ? asset($product->thumbnail) : null, - 'product_type' => $product->product_type, - 'specs' => [ - 'cpu' => (int) $product->cpu, - 'ram' => (int) $product->ram, - 'disk' => (int) $product->disk, - 'bandwidth' => (int) $product->bandwidth, - 'gpu' => $product->gpu, - ], - 'stock' => $product->stock, - 'price' => (int) $product->price, - 'price_sale' => $product->price_sale ? (int) $product->price_sale : null, - 'group' => $product->category->name ?? null, - 'os' => $product->osVersions->groupBy('os_group_id')->map(function ($versions) { - $group = $versions->first()->osGroup; - return [ - 'name' => $group?->name, - 'thumbnail' => $group?->thumbnail, - 'versions' => $versions->map(fn($os) => [ - 'id' => $os->id, - 'version' => $os->version, - 'name' => $os->name, - ])->values(), - ]; - })->values(), - 'pricing' => $product->pricings - ->sortBy(fn($p) => $periodMonths[$p->billing_cycle] ?? 999) - ->values() - ->map(fn($p) => [ - 'id' => $p->id, - 'name' => $billingCycleLabels[$p->billing_cycle] ?? $p->billing_cycle, - 'price' => (int) $p->price, - 'price_sale' => $p->price_sale ? (int) $p->price_sale : null, - 'period' => $periodMonths[$p->billing_cycle] ?? 1, - 'period_unit' => $periodUnits[$p->billing_cycle] ?? 'month', - ]), - ]; - } } diff --git a/app/Http/Controllers/Api/Service/ServiceController.php b/app/Http/Controllers/Api/Service/ServiceController.php index 86ee71d..4635936 100644 --- a/app/Http/Controllers/Api/Service/ServiceController.php +++ b/app/Http/Controllers/Api/Service/ServiceController.php @@ -156,7 +156,8 @@ class ServiceController extends Controller 'osVersions.osGroup', 'category', 'pricings' => fn($q) => $q->where('is_active', 1), - 'addons' + 'addons', + 'servicePlan', ]); return response()->json([ diff --git a/app/Http/Controllers/Api/Transaction/ManualDepositController.php b/app/Http/Controllers/Api/Transaction/ManualDepositController.php index 706e9d2..5922090 100644 --- a/app/Http/Controllers/Api/Transaction/ManualDepositController.php +++ b/app/Http/Controllers/Api/Transaction/ManualDepositController.php @@ -12,6 +12,7 @@ use App\Models\Log; use App\Models\Setting; use App\Models\Transaction; use App\Services\ActivityLogger; +use App\Support\ManualDepositDraft; use Illuminate\Http\JsonResponse; use Illuminate\Http\Request; use Illuminate\Support\Facades\DB; @@ -77,28 +78,17 @@ class ManualDepositController extends Controller ], 422); } - // Huỷ transaction prepare cũ (chưa có proof) nếu còn tồn tại - $old = Transaction::where('customer_id', $user->id) - ->where('type', 'manual') - ->where('status', TransactionStatus::PENDING->value) - ->whereNull('proof_image') - ->latest() - ->first(); - - if ($old) { - DB::transaction(function () use ($old, $user) { - $user->total_pending = max(0, $user->total_pending - $old->amount); - $user->save(); - $old->forceDelete(); - }); - } + ManualDepositDraft::query() + ->where('customer_id', $user->id) + ->get() + ->each(fn (Transaction $draft) => ManualDepositDraft::discard($draft)); $uuid = Str::uuid()->toString(); $publicCode = Transaction::newPublicCode('MAN'); DB::beginTransaction(); try { - Transaction::create([ + $draft = Transaction::create([ 'uuid' => $uuid, 'public_code' => $publicCode, 'trans' => 'MAN' . time(), @@ -108,9 +98,6 @@ class ManualDepositController extends Controller 'status' => TransactionStatus::PENDING->value, ]); - $user->total_pending += $request->amount; - $user->save(); - DB::commit(); } catch (\Exception) { DB::rollBack(); @@ -126,13 +113,43 @@ class ManualDepositController extends Controller return response()->json([ 'error' => false, - 'data' => [ - 'uuid' => $uuid, - 'public_code' => $publicCode, - ], + 'data' => $this->draftPayload($draft), ]); } + /** + * GET /v1/transaction/manual-deposit/{uuid} + */ + public function show(Request $request, string $uuid): JsonResponse + { + $draft = ManualDepositDraft::query() + ->where('uuid', $uuid) + ->where('customer_id', $request->user()->id) + ->first(); + + if (! $draft) { + return response()->json(['error' => true, 'message' => 'Mã thanh toán không tồn tại'], 404); + } + + if (ManualDepositDraft::isExpired($draft)) { + ManualDepositDraft::discard($draft); + + return response()->json(['error' => true, 'message' => 'Mã thanh toán đã hết hạn, vui lòng tạo mã mới'], 410); + } + + return response()->json(['error' => false, 'data' => $this->draftPayload($draft)]); + } + + private function draftPayload(Transaction $draft): array + { + return [ + 'uuid' => $draft->uuid, + 'public_code' => $draft->public_code, + 'amount' => (int) $draft->amount, + 'expires_at' => ManualDepositDraft::expiresAt($draft)->toISOString(), + ]; + } + /** * DELETE /v1/transaction/manual-deposit/{uuid}/cancel * Huỷ transaction đang prepare (chưa có proof, chưa submit). @@ -142,21 +159,15 @@ class ManualDepositController extends Controller { $user = $request->user(); - $transaction = Transaction::where('uuid', $uuid) + $transaction = ManualDepositDraft::query() + ->where('uuid', $uuid) ->where('customer_id', $user->id) - ->where('type', 'manual') - ->where('status', TransactionStatus::PENDING->value) - ->whereNull('proof_image') ->firstOrFail(); $amount = $transaction->amount; $publicCode = $transaction->public_code; - DB::transaction(function () use ($transaction, $user) { - $user->total_pending = max(0, $user->total_pending - $transaction->amount); - $user->save(); - $transaction->forceDelete(); - }); + ManualDepositDraft::discard($transaction); $this->activityLogger->log( event: 'deposit_cancel', @@ -194,22 +205,30 @@ class ManualDepositController extends Controller ], 422); } - $transaction = Transaction::where('uuid', $request->uuid) - ->where('customer_id', $user->id) - ->where('type', 'manual') - ->where('status', TransactionStatus::PENDING->value) - ->whereNull('proof_image') - ->first(); + $transaction = DB::transaction(function () use ($request, $user) { + $draft = ManualDepositDraft::query() + ->where('uuid', $request->uuid) + ->where('customer_id', $user->id) + ->lockForUpdate() + ->first(); + + if (! $draft || ManualDepositDraft::isExpired($draft)) { + return null; + } + + $draft->update(['proof_image' => $request->proof_image]); + $user->newQuery()->whereKey($user->getKey())->increment('total_pending', $draft->amount); + + return $draft; + }); if (!$transaction) { return response()->json([ 'error' => true, - 'message' => 'Giao dịch không hợp lệ hoặc đã được xử lý', + 'message' => 'Mã thanh toán đã hết hạn hoặc đã được xử lý, vui lòng tạo mã mới', ], 422); } - $transaction->update(['proof_image' => $request->proof_image]); - try { (new Telegram())->SendManualDepositPendingApproval($user, $transaction); $this->sendManualDepositAdminEmail($user, $transaction, $request); diff --git a/app/Http/Controllers/Api/Transaction/TransactionController.php b/app/Http/Controllers/Api/Transaction/TransactionController.php index b038b1f..06abdbd 100644 --- a/app/Http/Controllers/Api/Transaction/TransactionController.php +++ b/app/Http/Controllers/Api/Transaction/TransactionController.php @@ -266,12 +266,11 @@ class TransactionController extends Controller 'type' => 'deposit', 'public_code' => $t->public_code, 'trans' => $t->trans, - 'description' => 'Nạp tiền vào tài khoản', + 'description' => $t->typeLabel(), 'amount' => (float) $t->amount, 'status' => $t->status, 'status_label' => TransactionStatus::from((int) $t->status)->label(), - 'reference_id' => null, - 'reference_num'=> null, + 'detail_key' => $t->uuid, 'created_at' => $t->created_at?->toISOString(), ]); @@ -287,8 +286,7 @@ class TransactionController extends Controller 'amount' => (float) $inv->total, 'status' => $inv->status, 'status_label' => \App\Enums\Invoice\InvoiceStatus::tryFrom((int) $inv->status)?->label() ?? '-', - 'reference_id' => $inv->order?->id, - 'reference_num'=> $inv->order?->order_num, + 'detail_key' => $inv->order?->order_num, 'created_at' => $inv->created_at?->toISOString(), ]); @@ -303,6 +301,37 @@ class TransactionController extends Controller ]); } + public function getClientTransactionDetail(Request $request, string $uuid): JsonResponse + { + $transaction = Transaction::query() + ->where('uuid', $uuid) + ->where('customer_id', $request->user()->id) + ->first(); + + if (! $transaction) { + return response()->json(['error' => true, 'message' => 'Giao dịch không tồn tại'], 404); + } + + return response()->json([ + 'error' => false, + 'data' => [ + 'uuid' => $transaction->uuid, + 'public_code' => $transaction->public_code, + 'trans' => $transaction->trans, + 'type' => $transaction->type, + 'type_label' => $transaction->typeLabel(), + 'direction' => $transaction->direction, + 'amount' => (int) $transaction->amount, + 'received_amount' => $transaction->received_amount !== null ? (int) $transaction->received_amount : null, + 'status' => (int) $transaction->status, + 'status_label' => TransactionStatus::from((int) $transaction->status)->label(), + 'has_proof' => $transaction->proof_image !== null, + 'created_at' => $transaction->created_at?->toISOString(), + 'updated_at' => $transaction->updated_at?->toISOString(), + ], + ]); + } + public function getByPublicCode(Request $request) { $code = $request->query('code'); diff --git a/app/Http/Requests/Api/Order/StoreOrderRequest.php b/app/Http/Requests/Api/Order/StoreOrderRequest.php index 9d22fd3..92e8ab6 100644 --- a/app/Http/Requests/Api/Order/StoreOrderRequest.php +++ b/app/Http/Requests/Api/Order/StoreOrderRequest.php @@ -6,9 +6,26 @@ use Illuminate\Foundation\Http\FormRequest; use Illuminate\Validation\Rule; use App\Enums\Product\ProductBillingCycle; +use App\Enums\Product\ProductTypeEnums; +use App\Models\Product; class StoreOrderRequest extends FormRequest { + private const DOMAIN_PATTERN = '/^(?:[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?\\.)+[a-z]{2,63}$/i'; + + private function isHostingOrder(): bool + { + return Product::where('uuid', $this->input('product_id')) + ->value('product_type') === ProductTypeEnums::HOSTING->value; + } + + protected function prepareForValidation(): void + { + if (is_string($this->input('server_name'))) { + $this->merge(['server_name' => trim($this->input('server_name'))]); + } + } + public function authorize(): bool { return true; @@ -19,7 +36,9 @@ class StoreOrderRequest extends FormRequest return [ 'product_id' => ['required', Rule::exists('products', 'uuid')->whereNull('deleted_at')], 'billing_cycle'=> ['required', Rule::enum(ProductBillingCycle::class)], - 'server_name' => 'nullable|string|max:255', + 'server_name' => $this->isHostingOrder() + ? ['required', 'string', 'max:253', 'regex:'.self::DOMAIN_PATTERN] + : 'nullable|string|max:255', 'os_id' => 'nullable|integer', 'addons' => 'array', 'addons.*.addon_id' => 'nullable|exists:product_addon,id', @@ -35,6 +54,8 @@ class StoreOrderRequest extends FormRequest 'billing_cycle.required' => 'Vui lòng chọn chu kỳ thanh toán', 'billing_cycle.enum' => 'Chu kỳ thanh toán không hợp lệ', 'billing_cycle.exists' => 'Chu kỳ thanh toán không áp dụng cho sản phẩm này', + 'server_name.required' => 'Vui lòng nhập tên miền cho hosting', + 'server_name.regex' => 'Tên miền không hợp lệ (ví dụ: example.com)', 'os_id.exists' => 'Hệ điều hành không hợp lệ', 'addons.*.addon_id.exists' => 'Addon không tồn tại', 'addons.*.qty.min' => 'Số lượng addon phải lớn hơn 0', diff --git a/app/Http/Resources/Api/Client/ClientResource.php b/app/Http/Resources/Api/Client/ClientResource.php index bedbb8c..4242bdf 100644 --- a/app/Http/Resources/Api/Client/ClientResource.php +++ b/app/Http/Resources/Api/Client/ClientResource.php @@ -2,6 +2,7 @@ namespace App\Http\Resources\Api\Client; +use App\Services\Api\ServiceClient\ServiceClientService; use Illuminate\Http\Request; use Illuminate\Http\Resources\Json\JsonResource; use Illuminate\Support\Facades\URL; @@ -51,11 +52,8 @@ class ClientResource extends JsonResource 'point_total' => $this->point, ], 'service' => [ - 'vps' => $this->vps_count ?? $this->vps()->count(), 'proxy' => 0, - 'server' => 0, - 'hosting' => $this->hosting_count ?? $this->hosting()->count(), - 'domain' => $this->domain_count ?? $this->domain()->count(), + ...app(ServiceClientService::class)->countByType((int) $this->id), ] ]; } diff --git a/app/Http/Resources/Api/Client/Service/ServiceClientDetailResource.php b/app/Http/Resources/Api/Client/Service/ServiceClientDetailResource.php index abbba22..585fe84 100644 --- a/app/Http/Resources/Api/Client/Service/ServiceClientDetailResource.php +++ b/app/Http/Resources/Api/Client/Service/ServiceClientDetailResource.php @@ -82,7 +82,7 @@ class ServiceClientDetailResource extends JsonResource $orderItem = $this->orderItem; $order = $orderItem?->order; $vm = $this->resource->vmware; - $product = $orderItem?->product; + $product = $orderItem?->product ?? $this->resource->product; $hosting = $this->resource->hostingAccount; $pleskCredentials = $hosting ? $this->resolvePleskCredentials($hosting) : null; $dnsZone = $this->resolveDnsZone(); @@ -91,6 +91,7 @@ class ServiceClientDetailResource extends JsonResource return [ 'id' => $this->id, 'uuid' => $this->uuid, + 'type' => $this->type, 'server_name' => $this->server_name, 'status' => $this->status, 'auto_renew' => $this->auto_renew, @@ -100,11 +101,16 @@ class ServiceClientDetailResource extends JsonResource 'version' => $orderItem?->os?->version ?? $vm?->os?->version, 'group' => $orderItem?->os?->osGroup?->name ?? $vm?->os?->osGroup?->name, ], - 'specs' => $this->resolveSpecs($vm, $product), + 'specs' => $this->type === 'hosting' ? null : $this->resolveSpecs($vm, $product), + 'service_plan' => $this->type === 'hosting' ? $product?->servicePlan?->summary() : null, 'order' => $order ? [ 'total' => (float) $order->total, 'months' => (int) $orderItem->months, 'billing_label' => $orderItem->billing_label, + 'cycle_price' => (float) ($product?->pricings() + ->where('billing_cycle', $orderItem->billing_cycle) + ->where('is_active', 1) + ->value('price') ?? 0), 'addon_total' => \App\Support\RenewalPricing::addonTotal($this->resource, 1), ] : null, 'vm' => $vm ? [ diff --git a/app/Http/Resources/Api/Service/ServiceResource.php b/app/Http/Resources/Api/Service/ServiceResource.php index 09757e5..d5c370c 100644 --- a/app/Http/Resources/Api/Service/ServiceResource.php +++ b/app/Http/Resources/Api/Service/ServiceResource.php @@ -87,6 +87,9 @@ class ServiceResource extends JsonResource 'period' => $periodMonths[$p->billing_cycle] ?? 1, 'period_unit' => $periodUnits[$p->billing_cycle] ?? 'month', ]), + $this->mergeWhen($this->product_type === 'hosting', fn () => [ + 'service_plan' => $this->servicePlan?->summary(), + ]), ]; } diff --git a/app/Http/Resources/Api/Service/VpsServiceResource.php b/app/Http/Resources/Api/Service/VpsServiceResource.php index 635d15d..c4ec58f 100644 --- a/app/Http/Resources/Api/Service/VpsServiceResource.php +++ b/app/Http/Resources/Api/Service/VpsServiceResource.php @@ -2,17 +2,13 @@ namespace App\Http\Resources\Api\Service; +use App\Models\Product; use Illuminate\Http\Request; use Illuminate\Http\Resources\Json\JsonResource; class VpsServiceResource extends JsonResource { - /** - * Transform the resource into an array. - * - * @return array - */ - private function resolveSpecs($product): ?array + private function resolveSpecs(?Product $product): ?array { $addons = $this->addons ?? collect(); if ($addons->isNotEmpty()) { @@ -37,22 +33,19 @@ class VpsServiceResource extends JsonResource $invoice = $this->invoice; $order = $orderItem?->order ?? $invoice?->order; $product = $orderItem?->product ?? $this->product; + $type = $this->type ?? $product?->product_type; return [ 'uuid' => $this->uuid, + 'type' => $type, 'server_name' => $this->server_name, 'invoice_num' => $invoice?->invoice_number, - 'order_item_id' => $this->order_item_id, 'order_num' => $order?->order_num, 'status' => $this->status, 'status_label' => $this->status_label, 'auto_renew' => (bool) $this->auto_renew, 'started_at' => $this->started_at?->toISOString(), 'expires_at' => $this->expires_at?->toISOString(), - 'os' => [ - 'version' => $orderItem?->os?->version, - 'group' => $orderItem?->os?->osGroup?->name, - ], 'product' => $product ? [ 'uuid' => $product->uuid, 'name' => $product->name, @@ -60,7 +53,19 @@ class VpsServiceResource extends JsonResource 'product_type' => $product->product_type, 'page_type' => 'service_' . str_replace(' ', '_', $product->product_type), ] : null, - 'specs' => $this->resolveSpecs($product), + $this->mergeWhen($type === 'vps', fn () => [ + 'os' => [ + 'version' => $orderItem?->os?->version, + 'group' => $orderItem?->os?->osGroup?->name, + ], + 'specs' => $this->resolveSpecs($product), + ]), + $this->mergeWhen($type === 'hosting', fn () => [ + 'service_plan' => $product?->servicePlan?->summary(), + ]), + $this->mergeWhen($type === 'domain', fn () => [ + 'dns_records_count' => $this->dns_records_count, + ]), ]; } } diff --git a/app/Jobs/DnsManager/SyncDnsFromPlesk.php b/app/Jobs/DnsManager/SyncDnsFromPlesk.php index 776f3d4..92c32ae 100644 --- a/app/Jobs/DnsManager/SyncDnsFromPlesk.php +++ b/app/Jobs/DnsManager/SyncDnsFromPlesk.php @@ -3,8 +3,11 @@ namespace App\Jobs\DnsManager; use App\Events\DnsSyncCompleted; +use App\Models\DnsDomain; +use App\Models\DnsServer; use App\Repositories\DnsManager\Domain\DnsDomainRepositoryInterface; use App\Repositories\DnsManager\Record\DnsRecordRepositoryInterface; +use App\Services\Plesk\PleskDomainService; use App\Services\ServerService; use Illuminate\Bus\Queueable; use Illuminate\Contracts\Queue\ShouldQueue; @@ -19,19 +22,46 @@ class SyncDnsFromPlesk implements ShouldQueue private const PLESK_RECORD_TYPES = ['A', 'AAAA', 'CNAME', 'TXT', 'MX', 'NS', 'SRV']; + public function __construct(private readonly ?int $serverId = null) + { + } + public function handle( DnsDomainRepositoryInterface $domainRepository, DnsRecordRepositoryInterface $recordRepository, ServerService $serverService, + PleskDomainService $pleskDomainService, ): void { + $synced = 0; + $imported = 0; + $failedDomains = []; + + if ($this->serverId !== null) { + $server = DnsServer::find($this->serverId); + $pleskDomains = $server ? $pleskDomainService->listDomains($server) : false; + + if ($pleskDomains === false) { + Log::error('DNS sync: không lấy được danh sách domain từ Plesk', [ + 'server_id' => $this->serverId, + 'error' => $pleskDomainService->getLastError(), + ]); + event(new DnsSyncCompleted(0, ['server #' . $this->serverId], 0)); + + return; + } + + $imported = $this->importDomains($pleskDomains); + } + $domains = $domainRepository->model ->notCancelled() - ->whereNotNull('dns_manager_server_id') + ->when( + $this->serverId !== null, + fn ($query) => $query->where('dns_manager_server_id', $this->serverId), + fn ($query) => $query->whereNotNull('dns_manager_server_id'), + ) ->get(); - $synced = 0; - $failedDomains = []; - foreach ($domains as $domain) { if (!$serverService->addServerById($domain->dns_manager_server_id)) { $failedDomains[] = $domain->domain_name; @@ -63,9 +93,61 @@ class SyncDnsFromPlesk implements ShouldQueue } } - Log::info('DNS sync from Plesk finished', ['synced' => $synced, 'failed_domains' => $failedDomains]); + Log::info('DNS sync from Plesk finished', [ + 'server_id' => $this->serverId, + 'imported' => $imported, + 'synced' => $synced, + 'failed_domains' => $failedDomains, + ]); - event(new DnsSyncCompleted($synced, $failedDomains)); + event(new DnsSyncCompleted($synced, $failedDomains, $imported)); + } + + /** + * @param array> $pleskDomains + */ + private function importDomains(array $pleskDomains): int + { + $imported = 0; + + foreach ($pleskDomains as $item) { + $name = strtolower(trim((string) ($item['name'] ?? ''))); + + if ($name === '' || empty($item['id']) || !empty($item['base_domain_id'])) { + continue; + } + + $domain = DnsDomain::whereRaw('LOWER(domain_name) = ?', [$name])->first(); + + if (!$domain) { + DnsDomain::create([ + 'domain_name' => $name, + 'dns_manager_server_id' => $this->serverId, + 'plesk_id' => $item['id'], + 'is_created' => DnsDomain::STATUS_CREATED, + ]); + $imported++; + + continue; + } + + $sameServer = in_array((int) $domain->dns_manager_server_id, [0, $this->serverId], true); + + if ($domain->plesk_id || !$sameServer || (int) $domain->is_created === DnsDomain::STATUS_CANCELLED) { + continue; + } + + $domain->update([ + 'dns_manager_server_id' => $this->serverId, + 'plesk_id' => $item['id'], + 'is_created' => (int) $domain->is_created === DnsDomain::STATUS_PENDING + ? DnsDomain::STATUS_CREATED + : $domain->is_created, + ]); + $imported++; + } + + return $imported; } private function mapPleskRecord(array $item, string $type, string $domainName): array diff --git a/app/Models/DomainPricing.php b/app/Models/DomainPricing.php index 4e1e816..1cfe07d 100644 --- a/app/Models/DomainPricing.php +++ b/app/Models/DomainPricing.php @@ -88,16 +88,28 @@ class DomainPricing extends Model */ public static function findByDomain(string $domain): self { + return static::matchDomain($domain) + ?? throw new \InvalidArgumentException('Tên miền không được hỗ trợ. Vui lòng chọn đuôi tên miền có trong danh sách.'); + } + + public static function matchDomain(string $domain): ?self + { + $domain = mb_strtolower(trim($domain)); $candidates = static::domains()->where('is_active', true)->get() ->sortByDesc(fn (self $row) => strlen((string) $row->tld)); foreach ($candidates as $row) { - $tld = ltrim((string) $row->tld, '.'); + $tld = mb_strtolower(ltrim((string) $row->tld, '.')); if ($tld !== '' && str_ends_with($domain, '.' . $tld)) { return $row; } } - throw new \InvalidArgumentException('Tên miền không được hỗ trợ. Vui lòng chọn đuôi tên miền có trong danh sách.'); + return null; + } + + public static function yearsForMonths(int $months): int + { + return max(1, (int) ceil($months / 12)); } } diff --git a/app/Models/Product.php b/app/Models/Product.php index 20e045f..7243317 100644 --- a/app/Models/Product.php +++ b/app/Models/Product.php @@ -133,6 +133,11 @@ class Product extends Model return $this->belongsTo(ModuleIpAddress::class, 'module_ip_address'); } + public function servicePlan() + { + return $this->belongsTo(ServicePlan::class); + } + public function addons() { return $this->hasMany(Addon::class, 'type', 'product_type') diff --git a/app/Models/ServicePlan.php b/app/Models/ServicePlan.php index 68037fe..f4bade9 100644 --- a/app/Models/ServicePlan.php +++ b/app/Models/ServicePlan.php @@ -40,4 +40,21 @@ class ServicePlan extends Model { return $this->belongsTo(DnsServer::class, 'dns_manager_server_id'); } + + public function summary(): array + { + return [ + 'title' => $this->title, + 'vcpu' => $this->vcpu, + 'ram' => $this->ram, + 'disk' => $this->disk, + 'bandwidth' => $this->bandwidth, + 'databases' => $this->databases, + 'mailboxes' => $this->mailboxes, + 'ftp_accounts' => $this->ftp_accounts, + 'subdomains' => $this->subdomains, + 'domain_aliases' => $this->domain_aliases, + 'features' => $this->features ?? [], + ]; + } } diff --git a/app/Models/Transaction.php b/app/Models/Transaction.php index 812a543..4bffc48 100644 --- a/app/Models/Transaction.php +++ b/app/Models/Transaction.php @@ -82,6 +82,20 @@ class Transaction extends Model }; } + public function typeLabel(): string + { + return match ($this->type) { + 'manual' => 'Nạp tiền chuyển khoản', + 'vnpay' => 'Nạp tiền qua VNPay', + 'invoice' => 'Thanh toán hoá đơn', + 'refund' => 'Hoàn tiền', + 'admin_add_total', 'admin_add_credit' => 'Cộng số dư', + 'admin_deduct_credit' => 'Trừ số dư', + 'admin_record' => 'Ghi nhận giao dịch', + default => 'Giao dịch', + }; + } + public function getProofImageUrlsAttribute(): array { if (! $this->proof_image) { diff --git a/app/Providers/RouteServiceProvider.php b/app/Providers/RouteServiceProvider.php index 7aff6d0..acca38d 100644 --- a/app/Providers/RouteServiceProvider.php +++ b/app/Providers/RouteServiceProvider.php @@ -38,16 +38,16 @@ class RouteServiceProvider extends ServiceProvider $this->configureRateLimiting(); $this->routes(function () { - Route::prefix('api') - ->middleware(['api', 'json.response']) - ->namespace($this->namespace) - ->group(base_path('routes/api.php')); - Route::prefix('api') ->middleware(['api']) ->namespace($this->namespace) ->group(base_path('routes/admin-media.php')); + Route::prefix('api') + ->middleware(['api', 'json.response']) + ->namespace($this->namespace) + ->group(base_path('routes/api.php')); + Route::middleware(['api', 'json.response']) ->namespace($this->namespace) ->group(base_path('routes/client.php')); diff --git a/app/Repositories/Api/ServiceClient/ServiceClientRepository.php b/app/Repositories/Api/ServiceClient/ServiceClientRepository.php index 9953a5e..060c864 100644 --- a/app/Repositories/Api/ServiceClient/ServiceClientRepository.php +++ b/app/Repositories/Api/ServiceClient/ServiceClientRepository.php @@ -15,14 +15,14 @@ class ServiceClientRepository extends BaseApiRepository implements ServiceClient /** * Lấy toàn bộ dịch vụ của customer (mọi trạng thái) */ - public function getActiveServices(int $customerId, ?string $productType = null, int $limit = 10) + public function getActiveServices(int $customerId, ?string $productType = null, ?int $limit = 10) { $query = $this->model ->with([ - 'product', + 'product.servicePlan', 'addons', 'invoice.order', - 'orderItem' => fn($q) => $q->with(['product', 'os.osGroup', 'order']), + 'orderItem' => fn($q) => $q->with(['product.servicePlan', 'os.osGroup', 'order']), ]) ->where('customer_id', $customerId) ->orderByRaw("FIELD(status, 'active', 'pending', 'suspended', 'expired', 'cancelled', 'terminated')") @@ -32,7 +32,7 @@ class ServiceClientRepository extends BaseApiRepository implements ServiceClient $query->where('type', $productType); } - return $query->paginate($limit); + return $limit === null ? $query->get() : $query->paginate($limit); } public function findActiveBySlug(string $slug) diff --git a/app/Repositories/Api/ServiceClient/ServiceClientRepositoryInterface.php b/app/Repositories/Api/ServiceClient/ServiceClientRepositoryInterface.php index f21b1a9..5892d42 100644 --- a/app/Repositories/Api/ServiceClient/ServiceClientRepositoryInterface.php +++ b/app/Repositories/Api/ServiceClient/ServiceClientRepositoryInterface.php @@ -9,7 +9,7 @@ interface ServiceClientRepositoryInterface extends BaseRepositoryInterface /** * Lấy danh sách sản phẩm đang hoạt động */ - public function getActiveServices(int $customerId, ?string $productType = null, int $limit = 10); + public function getActiveServices(int $customerId, ?string $productType = null, ?int $limit = 10); public function findActiveBySlug(string $slug); diff --git a/app/Services/Api/Order/OrderQueryService.php b/app/Services/Api/Order/OrderQueryService.php index 6059a30..486e47b 100644 --- a/app/Services/Api/Order/OrderQueryService.php +++ b/app/Services/Api/Order/OrderQueryService.php @@ -8,7 +8,9 @@ use App\DTOs\Order\ClientOrderFilters; use App\Enums\Invoice\InvoiceStatus; use App\Helpers\OrderHelper; use App\Jobs\VirtualMachine\Provision\RetryProvisioningOrchestrator; +use App\Models\Customer; use App\Models\Invoice; +use App\Models\InvoiceItem; use App\Models\ModuleVmware; use App\Models\Order; use App\Models\OrderItem; @@ -16,6 +18,7 @@ use App\Models\Service; use App\Models\VirtualMachines; use App\Repositories\Api\Order\OrderRepositoryInterface; use App\Repositories\Api\Service\ServiceRepositoryInterface; +use App\Services\InvoicePaymentService; use App\Support\RenewalPricing; use Carbon\Carbon; use Illuminate\Contracts\Pagination\LengthAwarePaginator; @@ -26,6 +29,7 @@ class OrderQueryService public function __construct( private readonly OrderRepositoryInterface $orderRepository, private readonly ServiceRepositoryInterface $productRepo, + private readonly InvoicePaymentService $invoicePaymentService, ) { } @@ -166,12 +170,12 @@ class OrderQueryService return ['ok' => 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.']; } - public function renewService(int $serviceId, int $customerId, int $months): array + public function renewService(int $serviceId, Customer $customer, int $months): array { $service = Service::query() - ->with(['orderItem.product', 'invoice']) + ->with(['orderItem.product']) ->where('id', $serviceId) - ->where('customer_id', $customerId) + ->where('customer_id', $customer->id) ->whereNotIn('status', ['terminated', 'cancelled']) ->first(); @@ -197,89 +201,114 @@ class OrderQueryService $cycles = intdiv($months, $cycleMonths); - // Gia hạn luôn dùng giá sản phẩm hiện tại trong bảng giá, không dùng giá đã - // chốt lúc đặt mua ban đầu — giá cũ chỉ để hiển thị trên đơn hàng/hoá đơn gốc. - $currentPrice = $this->productRepo->getPricing((int) $service->product_id, $orderItem->billing_cycle); + $currentPrice = $this->productRepo->getPricing((int) ($service->product_id ?: $orderItem->product_id), $orderItem->billing_cycle); if ($currentPrice === null) { return ['ok' => false, 'message' => 'Sản phẩm hiện không còn bán, không thể gia hạn.', 'code' => 400]; } $renewalPrice = (int) $currentPrice * $cycles; - $addonTotal = RenewalPricing::addonTotal($service, $cycles); - $price = $renewalPrice + $addonTotal; - if ($service->invoice_id && Invoice::where('id', $service->invoice_id) - ->where('invoice_type', 2) - ->whereIn('status', [InvoiceStatus::UNPAID->value, InvoiceStatus::OVERDUE->value]) - ->exists()) { - $invoice = Invoice::find($service->invoice_id); + $invoice = DB::transaction(function () use ($service, $orderItem, $renewalPrice, $cycles, $months) { + $service = Service::query()->whereKey($service->id)->lockForUpdate()->first(); + $pending = $this->pendingRenewalInvoice($service); + if ($pending && ((int) $pending->amount_paid > 0 || $this->renewalMonths($pending) === $months)) { + return $pending; + } + + return $this->issueRenewalInvoice($service, $orderItem, $renewalPrice, $cycles, $months, $pending); + }); + + try { + $invoice = $this->invoicePaymentService->payWithBalance($invoice, $customer); + } catch (\InvalidArgumentException $e) { return [ - 'ok' => true, - 'message' => 'Dịch vụ đã có hóa đơn gia hạn đang chờ thanh toán.', - 'data' => [ - 'order_id' => $invoice?->order_id, - 'invoice_id' => $invoice?->id, - 'invoice_number' => $invoice?->invoice_number, - 'total' => (float) ($invoice?->total ?? 0), - 'status' => $invoice?->status, - 'payment_url' => $invoice?->clientPath(), - ], + 'ok' => false, + 'message' => $e->getMessage() . ' Hóa đơn gia hạn #' . $invoice->invoice_number . ' đang chờ thanh toán.', + 'code' => 422, + 'data' => $this->renewalPayload($invoice), ]; } - // Gia hạn là một billing cycle mới của CÙNG một Order/Service — không tạo Order mới. - // Order gốc chính là order đã tạo ra service này (qua order_item -> order_id). - $periodStart = $service->expires_at && $service->expires_at->isFuture() ? $service->expires_at->copy() : now(); - $periodEnd = $periodStart->copy()->addMonths($months); - - $invoice = DB::transaction(function () use ($service, $orderItem, $price, $renewalPrice, $cycles, $months, $periodStart, $periodEnd) { - $invoice = Invoice::create([ - 'invoice_number' => OrderHelper::generateOrderNumber('INV'), - 'customer_id' => $service->customer_id, - 'order_id' => $orderItem->order_id, - 'invoice_type' => 2, - 'sub_total' => $price, - 'discount_amount' => 0, - 'tax' => 0, - 'total' => $price, - 'status' => InvoiceStatus::UNPAID->value, - 'due_date' => now()->addDays(7)->toDateString(), - 'billing_period_start' => $periodStart->toDateString(), - 'billing_period_end' => $periodEnd->toDateString(), - ]); - - \App\Models\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' => $renewalPrice, - 'total_price' => $renewalPrice, - ]); - - foreach (RenewalPricing::addonInvoiceItems($service, $cycles, $months) as $addonItem) { - \App\Models\InvoiceItem::create($addonItem + ['invoice_id' => $invoice->id]); - } - - $service->update(['invoice_id' => $invoice->id]); - - return $invoice; - }); - return [ 'ok' => true, - 'message' => 'Đã tạo hóa đơn gia hạn. Vui lòng thanh toán để gia hạn dịch vụ.', - 'data' => [ - 'order_id' => $invoice->order_id, - 'invoice_id' => $invoice->id, - 'invoice_number' => $invoice->invoice_number, - 'total' => (float) $invoice->total, - 'status' => $invoice->status, - 'payment_url' => $invoice->clientPath(), + 'message' => 'Gia hạn dịch vụ thành công.', + 'data' => $this->renewalPayload($invoice) + [ + 'expires_at' => $service->fresh()->expires_at?->toISOString(), ], ]; } + + private function pendingRenewalInvoice(Service $service): ?Invoice + { + if (! $service->invoice_id) { + return null; + } + + return Invoice::query() + ->where('id', $service->invoice_id) + ->where('invoice_type', 2) + ->whereIn('status', [InvoiceStatus::UNPAID->value, InvoiceStatus::OVERDUE->value]) + ->first(); + } + + private function renewalMonths(Invoice $invoice): int + { + return (int) $invoice->invoiceItems()->where('type', 'renewal')->value('months'); + } + + private function issueRenewalInvoice(Service $service, OrderItem $orderItem, int $renewalPrice, int $cycles, int $months, ?Invoice $draft): Invoice + { + $price = $renewalPrice + RenewalPricing::addonTotal($service, $cycles); + $periodStart = $service->expires_at && $service->expires_at->isFuture() ? $service->expires_at->copy() : now(); + + $invoice = $draft ?? new Invoice([ + 'invoice_number' => OrderHelper::generateOrderNumber('INV'), + 'status' => InvoiceStatus::UNPAID->value, + ]); + $invoice->invoiceItems()->delete(); + $invoice->fill([ + 'customer_id' => $service->customer_id, + 'order_id' => $orderItem->order_id, + 'invoice_type' => 2, + 'sub_total' => $price, + 'discount_amount' => 0, + 'tax' => 0, + 'total' => $price, + 'due_date' => now()->addDays(7)->toDateString(), + 'billing_period_start' => $periodStart->toDateString(), + 'billing_period_end' => $periodStart->copy()->addMonths($months)->toDateString(), + ])->save(); + + InvoiceItem::create([ + 'invoice_id' => $invoice->id, + 'product_id' => $service->product_id ?: $orderItem->product_id, + 'type' => 'renewal', + 'description' => 'Gia hạn dịch vụ: ' . ($orderItem->product?->name ?? $service->server_name ?? ''), + 'months' => $months, + 'quantity' => 1, + 'unit_price' => $renewalPrice, + 'total_price' => $renewalPrice, + ]); + + foreach (RenewalPricing::addonInvoiceItems($service, $cycles, $months) as $addonItem) { + InvoiceItem::create($addonItem + ['invoice_id' => $invoice->id]); + } + + $service->update(['invoice_id' => $invoice->id]); + + return $invoice; + } + + private function renewalPayload(Invoice $invoice): array + { + return [ + 'order_id' => $invoice->order_id, + 'invoice_id' => $invoice->id, + 'invoice_number' => $invoice->invoice_number, + 'total' => (float) $invoice->total, + 'status' => $invoice->status, + 'payment_url' => $invoice->clientPath(), + ]; + } } diff --git a/app/Services/Api/ServiceClient/ServiceClientService.php b/app/Services/Api/ServiceClient/ServiceClientService.php index 6023fba..249b0d0 100644 --- a/app/Services/Api/ServiceClient/ServiceClientService.php +++ b/app/Services/Api/ServiceClient/ServiceClientService.php @@ -2,6 +2,8 @@ namespace App\Services\Api\ServiceClient; +use App\Models\DnsDomain; +use App\Models\Service; use App\Repositories\Api\ServiceClient\ServiceClientRepositoryInterface; class ServiceClientService @@ -16,11 +18,51 @@ class ServiceClientService /** * Lấy danh sách dịch vụ đang hoạt động */ - public function getActiveServices(int $customerId, ?string $productType = null, int $limit = 10) + public function getActiveServices(int $customerId, ?string $productType = null, ?int $limit = 10) { return $this->serviceClientRepository->getActiveServices($customerId, $productType, $limit); } + /** + * @return array{vps: int, server: int, hosting: int, domain: int} + */ + public function countByType(int $customerId): array + { + $counts = Service::query() + ->where('customer_id', $customerId) + ->whereNotIn('status', ['cancelled', 'terminated']) + ->selectRaw('type, COUNT(*) as total') + ->groupBy('type') + ->pluck('total', 'type'); + + return [ + 'vps' => (int) $counts->get('vps', 0), + 'server' => (int) $counts->get('server', 0), + 'hosting' => (int) $counts->get('hosting', 0), + 'domain' => (int) $counts->get('domain', 0), + ]; + } + + /** + * @param iterable $services + */ + public function attachDnsRecordCounts(iterable $services, int $customerId): void + { + $counts = DnsDomain::query() + ->withCount('ns') + ->whereHas('customer', fn ($query) => $query->where('customers.id', $customerId)) + ->get(['id', 'domain_name']) + ->mapWithKeys(fn (DnsDomain $domain) => [ + mb_strtolower(trim($domain->domain_name)) => (int) $domain->ns_count, + ]); + + foreach ($services as $service) { + if ($service->type === 'domain') { + $service->setAttribute('dns_records_count', $counts->get(mb_strtolower(trim((string) $service->server_name)))); + } + } + } + public function findActiveBySlug(string $slug) { return $this->serviceClientRepository->findActiveBySlug($slug); diff --git a/app/Services/Customer/CustomerImpersonationService.php b/app/Services/Customer/CustomerImpersonationService.php index 9f88f5e..2e3e538 100644 --- a/app/Services/Customer/CustomerImpersonationService.php +++ b/app/Services/Customer/CustomerImpersonationService.php @@ -36,7 +36,7 @@ class CustomerImpersonationService $token = JWTAuth::fromUser($customer); $refreshToken = $this->clientAuthService->createRefreshToken($customer->id); - $clientResource = (new ClientResource($customer->loadCount(['vps', 'hosting', 'domain'])))->resolve($request); + $clientResource = (new ClientResource($customer))->resolve($request); $code = Str::random(48); Cache::put("impersonate:{$code}", [ diff --git a/app/Services/Customer/CustomerServiceProvisioningService.php b/app/Services/Customer/CustomerServiceProvisioningService.php index 7fc971a..ec19bd8 100644 --- a/app/Services/Customer/CustomerServiceProvisioningService.php +++ b/app/Services/Customer/CustomerServiceProvisioningService.php @@ -2,16 +2,11 @@ namespace App\Services\Customer; -use App\Enums\Invoice\InvoiceStatus; -use App\Enums\Order\OrderStatus; use App\Helpers\OrderHelper; use App\Mail\ServiceRenewalReminderMail; use App\Models\Customer; -use App\Models\Invoice; -use App\Models\InvoiceItem; use App\Models\ModuleVmware; use App\Models\Order; -use App\Models\OrderItem; use App\Models\Product; use App\Models\Service; use App\Models\Transaction; @@ -21,6 +16,7 @@ use App\Repositories\Module\Hosting\HostingRepositoryInterface as ModuleHostingR use App\Repositories\Module\Vmware\VmwareRepositoryInterface; use App\Repositories\Service\ServiceRepositoryInterface; use App\Services\Customer\Concerns\ResolvesCustomer; +use App\Services\Order\ServiceOrderRecorder; use Carbon\Carbon; use Illuminate\Support\Collection; use Illuminate\Support\Facades\Auth; @@ -37,6 +33,7 @@ class CustomerServiceProvisioningService private readonly ModuleHostingRepositoryInterface $moduleHostingRepository, private readonly VmwareRepositoryInterface $vmwareRepository, private readonly ServiceRepositoryInterface $serviceRepository, + private readonly ServiceOrderRecorder $orderRecorder, ) {} public function getServices(string $uuid): Collection @@ -89,8 +86,11 @@ class CustomerServiceProvisioningService $this->assignVirtualMachine($service, $customer, (int) $data['virtual_machine_id']); } - if (array_key_exists('price', $data) && $data['price'] !== null && $service->orderItem) { - $service->orderItem->update(['price' => $data['price']]); + $price = isset($data['price']) ? (int) $data['price'] : null; + + if (!$service->order_item_id && ($price !== null || !empty($data['billing_cycle']))) { + $this->orderRecorder->ensureForService($service, $price, $data['billing_cycle'] ?? null); + $service->refresh(); } if (!empty($data['billing_cycle']) && $service->orderItem) { @@ -100,6 +100,11 @@ class CustomerServiceProvisioningService ]); } + if ($price !== null && $service->orderItem) { + $service->orderItem->update(['price' => $price]); + $this->orderRecorder->syncUnpaidInvoice($service->refresh(), $price); + } + // Đồng bộ sang module_vmware nếu có gán VM if ($service->vm_id) { $vmData = array_filter([ @@ -196,7 +201,7 @@ class CustomerServiceProvisioningService { $customer = $this->resolveCustomer($uuid); - $product = Product::with('pricings')->find($data['product_id']); + $product = Product::find($data['product_id']); abort_if( $product->product_type === 'vps' && empty($data['virtual_machine_id']), 422, @@ -206,9 +211,14 @@ class CustomerServiceProvisioningService $billingCycle = $data['billing_cycle'] ?? 'monthly'; $months = OrderHelper::cycleToMonths($billingCycle); - $pricing = $product->pricings->where('billing_cycle', $billingCycle)->first(); // Số tiền đã thanh toán: admin nhập tay để khớp với thực tế (có thể lệch giá niêm yết do khuyến mãi/thoả thuận) - $basePrice = isset($data['amount_paid']) ? (int) $data['amount_paid'] : (int) ($pricing->price ?? 0); + $listPrice = $this->orderRecorder->listPrice($product, $billingCycle, $data['server_name'] ?? null); + abort_if( + !isset($data['amount_paid']) && $listPrice === null && $product->product_type === 'domain', + 422, + 'Tên miền không có giá trong bảng giá tên miền, vui lòng nhập giá.', + ); + $basePrice = isset($data['amount_paid']) ? (int) $data['amount_paid'] : (int) $listPrice; $paymentMethod = $data['payment_method']; @@ -224,66 +234,25 @@ class CustomerServiceProvisioningService ); return DB::transaction(function () use ($customer, $product, $data, $billingCycle, $months, $basePrice, $paymentMethod, $clientIp) { - // 1) Order + OrderItem (mô phỏng flow customer đặt hàng) - $order = Order::create([ - 'order_num' => OrderHelper::generateOrderNumber(), - 'customer_id' => $customer->id, - 'date' => now()->toDateString(), - 'sub_total' => $basePrice, - 'discount_amount' => 0, - 'total' => $basePrice, - 'status' => OrderStatus::ACTIVE->value, - 'payment_method' => $paymentMethod, - 'client_ip' => $clientIp, - ]); - - $orderItem = OrderItem::create([ - 'order_id' => $order->id, - 'product_id' => $product->id, - 'billing_cycle' => $billingCycle, - 'months' => $months, - 'server_name' => $data['server_name'] ?? null, - 'quantity' => 1, - 'price' => $basePrice, - ]); + ['order' => $order, 'orderItem' => $orderItem, 'invoice' => $invoice] = $this->orderRecorder->create( + $customer->id, + $product, + $billingCycle, + $basePrice, + $data['server_name'] ?? null, + paid: true, + paymentMethod: $paymentMethod, + clientIp: $clientIp, + ); // Ngày bắt đầu/hết hạn dùng chung cho cả module_vmware (next_due_date/next_invoice) và services. $startedAt = $data['started_at'] ?? now(); $expiresAt = $data['expires_at'] ?? Carbon::parse($startedAt)->addMonths($months); - // 2) ModuleVmware: snapshot từ virtual_machines. `order_id` phải trỏ về - // order vừa tạo — client console-ticket/rebuild resolve VM qua - // service->orderItem->order->vmWare, không qua services.vm_id. + // module_vmware.order_id phải trỏ về order vừa tạo — client console-ticket/rebuild + // resolve VM qua service->orderItem->order->vmWare, không qua services.vm_id. $vmId = $this->createModuleVmwareIfNeeded($data, $customer, $order, $basePrice, $expiresAt); - // 3) Invoice — đánh dấu PAID luôn vì admin đứng ra tạo & xác nhận thanh toán - $invoice = Invoice::create([ - 'invoice_number' => OrderHelper::generateOrderNumber('INV'), - 'customer_id' => $customer->id, - 'order_id' => $order->id, - 'invoice_type' => 1, - 'date' => now()->toDateString(), - 'sub_total' => $basePrice, - 'discount_amount' => 0, - 'tax' => 0, - 'total' => $basePrice, - 'status' => InvoiceStatus::PAID->value, - 'payment_method' => $paymentMethod, - 'paid_at' => now(), - 'due_date' => now()->addMonths($months)->toDateString(), - ]); - - InvoiceItem::create([ - 'invoice_id' => $invoice->id, - 'product_id' => $product->id, - 'type' => 'service', - 'description' => $product->name ?? 'Dịch vụ', - 'months' => $months, - 'quantity' => 1, - 'unit_price' => $basePrice, - 'total_price' => $basePrice, - ]); - // 4) Payment processing if ($paymentMethod === 'balance') { $customer->total = (int) $customer->total - $basePrice; diff --git a/app/Services/Domain/CustomerDomainServiceSync.php b/app/Services/Domain/CustomerDomainServiceSync.php new file mode 100644 index 0000000..a0eafb3 --- /dev/null +++ b/app/Services/Domain/CustomerDomainServiceSync.php @@ -0,0 +1,116 @@ +map(fn ($id) => (int) $id)->unique()->values(); + $previousCustomerIds = $domain->customer()->pluck('customers.id'); + $oldName = $previousDomainName ?? $domain->domain_name; + + DB::transaction(function () use ($domain, $customerIds, $previousCustomerIds, $oldName): void { + $domain->customer()->sync($customerIds->all()); + + foreach ($customerIds as $customerId) { + $service = $this->findService($customerId, $oldName, $domain->domain_name); + + if ($service?->trashed()) { + $service->restore(); + } + + $service ??= new Service([ + 'customer_id' => $customerId, + 'type' => 'domain', + 'note' => self::AUTO_CREATED_NOTE, + ]); + + $service->fill([ + 'server_name' => $domain->domain_name, + 'status' => $this->serviceStatus($domain), + 'started_at' => $service->started_at ?? $domain->created_at ?? Carbon::now(), + ])->save(); + + if ($service->status !== 'cancelled') { + $this->orderRecorder->ensureForService($service); + } + } + + $removedCustomerIds = $previousCustomerIds->diff($customerIds); + if ($removedCustomerIds->isNotEmpty()) { + Service::query() + ->whereIn('customer_id', $removedCustomerIds) + ->where('type', 'domain') + ->whereIn(DB::raw('LOWER(TRIM(server_name))'), array_unique([ + $this->domainKey($oldName), + $this->domainKey($domain->domain_name), + ])) + ->get() + ->each(function (Service $service): void { + $this->orderRecorder->cancelUnpaidInvoice($service); + $service->delete(); + }); + } + }); + } + + public function updateCustomerDomain( + DnsDomain $domain, + int $customerId, + ?string $expiresAt, + ): Service { + $this->syncCustomers($domain, $domain->customer()->pluck('customers.id')->all()); + + $service = $this->findService($customerId, $domain->domain_name, $domain->domain_name); + abort_if(! $service, 404, 'Không tìm thấy dịch vụ tên miền'); + + $service->fill([ + 'status' => $this->serviceStatus($domain), + 'started_at' => $domain->created_at, + 'expires_at' => $expiresAt + ? Carbon::parse($expiresAt)->setTimeFrom($service->expires_at ?? Carbon::today()) + : null, + ])->save(); + + return $service; + } + + private function findService(int $customerId, string $oldName, string $currentName): ?Service + { + return Service::withTrashed() + ->where('customer_id', $customerId) + ->where('type', 'domain') + ->whereIn(DB::raw('LOWER(TRIM(server_name))'), array_unique([ + $this->domainKey($oldName), + $this->domainKey($currentName), + ])) + ->orderBy('id') + ->first(); + } + + private function serviceStatus(DnsDomain $domain): string + { + return match ((int) $domain->is_created) { + DnsDomain::STATUS_CREATED => 'active', + DnsDomain::STATUS_INACTIVE => 'suspended', + DnsDomain::STATUS_CANCELLED => 'cancelled', + default => 'pending', + }; + } + + private function domainKey(?string $domainName): string + { + return mb_strtolower(trim((string) $domainName)); + } +} diff --git a/app/Services/Order/ServiceOrderRecorder.php b/app/Services/Order/ServiceOrderRecorder.php new file mode 100644 index 0000000..3d3382e --- /dev/null +++ b/app/Services/Order/ServiceOrderRecorder.php @@ -0,0 +1,171 @@ +product_type === 'domain'; + + $order = Order::create([ + 'order_num' => OrderHelper::generateOrderNumber(), + 'customer_id' => $customerId, + 'date' => now()->toDateString(), + 'sub_total' => $price, + 'discount_amount' => 0, + 'total' => $price, + 'status' => $paid ? OrderStatus::ACTIVE->value : OrderStatus::PENDING->value, + 'payment_method' => $paymentMethod, + 'client_ip' => $clientIp, + ]); + + $orderItem = OrderItem::create([ + 'order_id' => $order->id, + 'product_id' => $product?->id, + 'billing_cycle' => $billingCycle, + 'months' => $months, + 'server_name' => $serverName, + 'domain_name' => $isDomain ? $serverName : null, + 'quantity' => 1, + 'price' => $price, + ]); + + $invoice = Invoice::create([ + 'invoice_number' => OrderHelper::generateOrderNumber('INV'), + 'customer_id' => $customerId, + 'order_id' => $order->id, + 'invoice_type' => 1, + 'date' => now()->toDateString(), + 'sub_total' => $price, + 'discount_amount' => 0, + 'tax' => 0, + 'total' => $price, + 'status' => $paid ? InvoiceStatus::PAID->value : InvoiceStatus::UNPAID->value, + 'payment_method' => $paymentMethod, + 'paid_at' => $paid ? now() : null, + 'due_date' => $paid ? now()->addMonths($months)->toDateString() : now()->toDateString(), + ]); + + InvoiceItem::create([ + 'invoice_id' => $invoice->id, + 'product_id' => $product?->id, + 'type' => 'service', + 'description' => $this->describe($product, $serverName), + 'months' => $months, + 'quantity' => 1, + 'unit_price' => $price, + 'total_price' => $price, + ]); + + return compact('order', 'orderItem', 'invoice'); + } + + public function ensureForService(Service $service, ?int $price = null, ?string $billingCycle = null): void + { + if ($service->order_item_id || ! $service->customer_id) { + return; + } + + DB::transaction(function () use ($service, $price, $billingCycle) { + $product = $service->product ?? $this->defaultProductFor($service->type); + $isDomain = ($product?->product_type ?? $service->type) === 'domain'; + $billingCycle ??= $isDomain ? 'annually' : 'monthly'; + $price ??= $this->listPrice($product, $billingCycle, $service->server_name) ?? 0; + + $records = $this->create( + (int) $service->customer_id, + $product, + $billingCycle, + $price, + $service->server_name, + paid: false, + ); + + $service->forceFill([ + 'product_id' => $service->product_id ?? $product?->id, + 'order_item_id' => $records['orderItem']->id, + 'invoice_id' => $records['invoice']->id, + ])->save(); + }); + } + + public function syncUnpaidInvoice(Service $service, int $price): void + { + $invoice = $service->invoice; + if (! $invoice || (int) $invoice->status !== InvoiceStatus::UNPAID->value || (int) $invoice->invoice_type !== 1) { + return; + } + + $months = (int) ($service->orderItem?->months ?: 1); + + $invoice->update(['sub_total' => $price, 'total' => $price]); + $invoice->invoiceItems()->where('type', 'service')->update([ + 'months' => $months, + 'unit_price' => $price, + 'total_price' => $price, + ]); + $invoice->order?->update(['sub_total' => $price, 'total' => $price]); + } + + public function cancelUnpaidInvoice(Service $service): void + { + $invoice = $service->invoice; + if (! $invoice || (int) $invoice->status !== InvoiceStatus::UNPAID->value) { + return; + } + + $invoice->update(['status' => InvoiceStatus::CANCELLED->value]); + $invoice->order?->update(['status' => OrderStatus::CANCELLED->value]); + } + + public function listPrice(?Product $product, string $billingCycle, ?string $serverName): ?int + { + if ($product?->product_type === 'domain') { + return DomainPricing::matchDomain((string) $serverName) + ?->orderPrice(DomainPricing::yearsForMonths(OrderHelper::cycleToMonths($billingCycle))); + } + + $price = $product?->pricings()->where('billing_cycle', $billingCycle)->value('price'); + + return $price === null ? null : (int) $price; + } + + private function defaultProductFor(?string $type): ?Product + { + return $type + ? Product::where('product_type', $type)->where('is_active', true)->orderBy('id')->first() + : null; + } + + private function describe(?Product $product, ?string $serverName): string + { + $name = $product->name ?? 'Dịch vụ'; + + return $serverName ? "{$name} - {$serverName}" : $name; + } +} diff --git a/app/Services/Plesk/PleskDomainService.php b/app/Services/Plesk/PleskDomainService.php index 6b1da42..ff2392b 100644 --- a/app/Services/Plesk/PleskDomainService.php +++ b/app/Services/Plesk/PleskDomainService.php @@ -106,6 +106,31 @@ class PleskDomainService return false; } + /** + * @return array>|false + */ + public function listDomains(DnsServer $server): array|false + { + $this->lastError = null; + + try { + $response = $this->plesk->client($server, 30) + ->get($this->plesk->url($server, 'domains')); + } catch (ConnectionException $e) { + $this->lastError = $e->getMessage(); + + return false; + } + + if (!$response->successful() || !is_array($response->json())) { + $this->lastError = $response->json('message') ?? $response->body(); + + return false; + } + + return $response->json(); + } + public function getDomainInfo(DnsServer $server, string $domain = ''): array|false { if (empty($domain)) { diff --git a/app/Support/ManualDepositDraft.php b/app/Support/ManualDepositDraft.php new file mode 100644 index 0000000..8a12dca --- /dev/null +++ b/app/Support/ManualDepositDraft.php @@ -0,0 +1,46 @@ +where('type', 'manual') + ->where('status', TransactionStatus::PENDING->value) + ->whereNull('proof_image'); + } + + public static function expiresAt(Transaction $draft): CarbonInterface + { + return $draft->created_at->copy()->addMinutes(self::TTL_MINUTES); + } + + public static function isExpired(Transaction $draft): bool + { + return self::expiresAt($draft)->isPast(); + } + + public static function expiredQuery(): Builder + { + return self::query()->where('created_at', '<=', now()->subMinutes(self::TTL_MINUTES)); + } + + public static function discard(Transaction $draft): bool + { + return DB::transaction(function () use ($draft) { + $locked = self::query()->whereKey($draft->getKey())->lockForUpdate()->first(); + + return $locked ? (bool) $locked->forceDelete() : false; + }); + } +} diff --git a/database/migrations/2026_10_11_000002_backfill_dns_domain_services.php b/database/migrations/2026_10_11_000002_backfill_dns_domain_services.php new file mode 100644 index 0000000..0a5d64b --- /dev/null +++ b/database/migrations/2026_10_11_000002_backfill_dns_domain_services.php @@ -0,0 +1,69 @@ +join('dns_manager_domain', 'dns_manager_domain.id', '=', 'dns_domain_user.domain_id') + ->select([ + 'dns_domain_user.user_id as customer_id', + 'dns_manager_domain.domain_name', + 'dns_manager_domain.is_created', + 'dns_manager_domain.created_at', + ]) + ->orderBy('dns_domain_user.domain_id') + ->each(function ($assignment): void { + $existing = DB::table('services') + ->where('customer_id', $assignment->customer_id) + ->where('type', 'domain') + ->whereRaw('LOWER(TRIM(server_name)) = ?', [mb_strtolower(trim($assignment->domain_name))]) + ->orderBy('id') + ->first(); + + if ($existing) { + if ($existing->deleted_at !== null) { + DB::table('services')->where('id', $existing->id)->update([ + 'deleted_at' => null, + 'updated_at' => now(), + ]); + } + return; + } + + DB::table('services')->insert([ + 'uuid' => (string) Str::uuid(), + 'customer_id' => $assignment->customer_id, + 'type' => 'domain', + 'server_name' => $assignment->domain_name, + 'status' => match ((int) $assignment->is_created) { + 1 => 'active', + 2 => 'suspended', + 3 => 'cancelled', + default => 'pending', + }, + 'auto_renew' => false, + 'started_at' => $assignment->created_at, + 'note' => CustomerDomainServiceSync::AUTO_CREATED_NOTE, + 'created_at' => now(), + 'updated_at' => now(), + ]); + }); + } + + public function down(): void + { + DB::table('services') + ->where('type', 'domain') + ->where('note', CustomerDomainServiceSync::AUTO_CREATED_NOTE) + ->whereNull('product_id') + ->whereNull('order_item_id') + ->whereNull('invoice_id') + ->delete(); + } +}; diff --git a/database/migrations/2026_10_11_000003_recalculate_customer_total_pending.php b/database/migrations/2026_10_11_000003_recalculate_customer_total_pending.php new file mode 100644 index 0000000..468a783 --- /dev/null +++ b/database/migrations/2026_10_11_000003_recalculate_customer_total_pending.php @@ -0,0 +1,25 @@ +selectRaw('COALESCE(SUM(amount), 0)') + ->whereColumn('transactions.customer_id', 'customers.id') + ->where('type', 'manual') + ->where('status', TransactionStatus::PENDING->value) + ->whereNotNull('proof_image') + ->whereNull('deleted_at'); + + DB::table('customers')->update(['total_pending' => DB::raw('(' . $submittedPending->toRawSql() . ')')]); + } + + public function down(): void + { + } +}; diff --git a/routes/api/admin/billing.php b/routes/api/admin/billing.php index 326bd5a..b4e1434 100644 --- a/routes/api/admin/billing.php +++ b/routes/api/admin/billing.php @@ -91,4 +91,5 @@ Route::prefix('transactions') Route::get('pending-count', 'pendingCount')->middleware('perm:customer.view'); Route::post('{uuid}/approve', 'approve')->middleware('perm:customer.edit'); Route::post('{uuid}/reject', 'reject')->middleware('perm:customer.edit'); + Route::delete('{uuid}', 'destroy')->middleware('perm:customer.edit'); }); diff --git a/routes/api/admin/customers.php b/routes/api/admin/customers.php index 043fefe..bc2ce58 100644 --- a/routes/api/admin/customers.php +++ b/routes/api/admin/customers.php @@ -60,6 +60,9 @@ Route::prefix('customers') ); Route::get('{uuid}/domains', 'getDomains')->middleware('perm:customer.view'); Route::get('{uuid}/dns-domains', 'getDnsDomains')->middleware('perm:customer.view'); + Route::post('{uuid}/dns-domains/assign', 'assignDnsDomain')->middleware('perm:customer.edit'); + Route::put('{uuid}/dns-domains/{domainId}', 'updateDnsDomain')->middleware('perm:customer.edit'); + Route::delete('{uuid}/dns-domains/{domainId}', 'detachDnsDomain')->middleware('perm:customer.edit'); Route::get('{uuid}/dns-domains/{domainId}/records', 'getDnsDomainRecords')->middleware( 'perm:customer.view', ); @@ -81,6 +84,7 @@ Route::prefix('services/renewal-reminders')->controller(ServiceRenewalReminderCo Route::prefix('services')->controller(ServiceController::class)->group(function () { Route::get('/', 'index')->middleware('perm:customer.view'); Route::post('bulk-action', 'bulkAction')->middleware('perm:customer.edit'); + Route::get('domain-quote', 'domainQuote')->middleware('perm:customer.edit'); Route::get('{id}', 'show')->middleware('perm:customer.view'); Route::patch('{id}/detach-customer', 'detachCustomer')->middleware('perm:customer.edit'); Route::post('{id}/resend-credentials', 'resendCredentials')->middleware('perm:customer.edit'); diff --git a/routes/client.php b/routes/client.php index 3d0f15b..f391e5f 100644 --- a/routes/client.php +++ b/routes/client.php @@ -104,6 +104,7 @@ Route::prefix('v1') ->controller(ClientServiceController::class) ->as('client-service.') ->group(function () { + Route::get('/', 'index')->name('list'); Route::get('vps', 'indexVps')->name('vps.list'); Route::get('hosting', 'indexHosting')->name('hosting.list'); Route::get('web-design', 'indexWebDesign')->name('web-design.list'); @@ -132,6 +133,7 @@ Route::prefix('v1') ->controller(TransactionController::class) ->group(function () { Route::get('payment-history', 'getClientPaymentHistory')->name('payment-history.list'); + Route::get('payment-history/{uuid}', 'getClientTransactionDetail')->whereUuid('uuid')->name('payment-history.detail'); }); Route::middleware(['api.auth']) @@ -269,6 +271,7 @@ Route::prefix('v1') Route::post('manual-deposit/confirm', [ManualDepositController::class, 'confirm'])->name('manualDeposit.confirm'); Route::delete('manual-deposit/{uuid}/cancel', [ManualDepositController::class, 'cancel'])->name('manualDeposit.cancel'); Route::post('manual-deposit/upload-proof', [ManualDepositController::class, 'uploadProof'])->name('manualDeposit.uploadProof'); + Route::get('manual-deposit/{uuid}', [ManualDepositController::class, 'show'])->whereUuid('uuid')->name('manualDeposit.show'); }); }); diff --git a/tests/Feature/Api/Admin/Customer/CreateDomainServicePricingTest.php b/tests/Feature/Api/Admin/Customer/CreateDomainServicePricingTest.php new file mode 100644 index 0000000..9d74e42 --- /dev/null +++ b/tests/Feature/Api/Admin/Customer/CreateDomainServicePricingTest.php @@ -0,0 +1,105 @@ + 'Full Administrator', 'guard_name' => 'admin']); + $this->admin = User::factory()->create(); + $this->admin->assignRole('Full Administrator'); + $this->customer = Customer::factory()->create(); + $this->product = Product::factory()->create(['product_type' => 'domain']); + + DomainPricing::query()->delete(); + DomainPricing::create(['type' => 'domain', 'tld' => '.vn', 'setup_fee' => '100000', 'first_year' => 400_000, 'recurring_fee' => '350000', 'is_active' => true]); + DomainPricing::create(['type' => 'domain', 'tld' => '.com.vn', 'setup_fee' => 'Miễn phí', 'first_year' => 300_000, 'recurring_fee' => '300000', 'is_active' => true]); + } + + private function createService(array $data) + { + return $this->actingAs($this->admin, 'admin')->postJson( + "/api/v1/administrator/customers/{$this->customer->uuid}/services", + ['product_id' => $this->product->id, 'payment_method' => 'transfer', 'billing_cycle' => 'biennially', ...$data], + ); + } + + public function test_domain_service_uses_domain_pricing_for_matched_tld(): void + { + $this->createService(['server_name' => 'Example.COM.vn'])->assertCreated(); + $this->createService(['server_name' => 'example.vn'])->assertCreated(); + + $this->assertDatabaseHas('order_items', ['domain_name' => 'Example.COM.vn', 'price' => 600_000]); + $this->assertDatabaseHas('order_items', ['domain_name' => 'example.vn', 'price' => 850_000]); + } + + public function test_domain_without_pricing_requires_amount(): void + { + $this->createService(['server_name' => 'example.xyz'])->assertStatus(422); + $this->assertSame(0, Service::count()); + + $this->createService(['server_name' => 'example.xyz', 'amount_paid' => 250_000])->assertCreated(); + $this->assertDatabaseHas('order_items', ['domain_name' => 'example.xyz', 'price' => 250_000]); + } + + public function test_saving_price_on_service_without_order_creates_unpaid_order_and_invoice(): void + { + $service = Service::create([ + 'customer_id' => $this->customer->id, + 'product_id' => $this->product->id, + 'server_name' => 'example.vn', + 'status' => 'active', + ]); + $url = "/api/v1/administrator/customers/{$this->customer->uuid}/services/{$service->id}"; + + $this->actingAs($this->admin, 'admin')->putJson($url, ['price' => 500_000, 'billing_cycle' => 'annually'])->assertOk(); + + $service->refresh(); + $this->assertSame(500_000, (int) $service->orderItem->price); + $this->assertSame(1, (int) $service->invoice->status); + $this->assertSame(500_000, (int) $service->invoice->total); + + $this->actingAs($this->admin, 'admin')->putJson($url, ['price' => 450_000])->assertOk(); + + $this->assertSame(450_000, (int) $service->invoice->fresh()->total); + $this->assertSame(450_000, (int) $service->invoice->invoiceItems()->value('total_price')); + $this->assertDatabaseCount('invoices', 1); + } + + public function test_domain_quote_endpoint(): void + { + $url = '/api/v1/administrator/services/domain-quote'; + + $this->actingAs($this->admin, 'admin')->getJson("{$url}?domain=abc.vn&billing_cycle=triennially") + ->assertOk() + ->assertJsonPath('data.tld', '.vn') + ->assertJsonPath('data.years', 3) + ->assertJsonPath('data.price', 1_200_000); + + $this->actingAs($this->admin, 'admin')->getJson("{$url}?domain=abc.xyz") + ->assertOk() + ->assertJsonPath('data', null); + } +} diff --git a/tests/Feature/Api/Admin/CustomerDomainServiceSyncTest.php b/tests/Feature/Api/Admin/CustomerDomainServiceSyncTest.php new file mode 100644 index 0000000..9fa01e2 --- /dev/null +++ b/tests/Feature/Api/Admin/CustomerDomainServiceSyncTest.php @@ -0,0 +1,72 @@ +create(); + $serverId = DB::table('dns_manager_servers')->insertGetId([ + 'ip_address' => '10.0.0.1', + 'password' => 'x', + ]); + $domain = DnsDomain::create([ + 'domain_name' => 'devro-tech.com', + 'dns_manager_server_id' => $serverId, + 'is_created' => DnsDomain::STATUS_CREATED, + ]); + DomainPricing::query()->delete(); + DomainPricing::create(['type' => 'domain', 'tld' => '.com', 'setup_fee' => '0', 'first_year' => 320_000, 'recurring_fee' => '340000', 'is_active' => true]); + $product = Product::factory()->create(['product_type' => 'domain', 'is_active' => true]); + $sync = app(CustomerDomainServiceSync::class); + + $sync->syncCustomers($domain, [$customer->id]); + + $service = Service::query() + ->where('customer_id', $customer->id) + ->where('type', 'domain') + ->where('server_name', 'devro-tech.com') + ->firstOrFail(); + + $this->assertSame('active', $service->status); + $this->assertSame(CustomerDomainServiceSync::AUTO_CREATED_NOTE, $service->note); + $this->assertSame($product->id, $service->product_id); + $this->assertSame('devro-tech.com', $service->orderItem->domain_name); + $this->assertSame(320_000, (int) $service->orderItem->price); + $this->assertSame('annually', $service->orderItem->billing_cycle); + $this->assertSame(1, (int) $service->invoice->status); + $this->assertSame(320_000, (int) $service->invoice->total); + + $sync->syncCustomers($domain, [$customer->id]); + $this->assertSame(1, Service::query()->where('customer_id', $customer->id)->count()); + $this->assertSame(1, $service->orderItem->order()->count()); + $this->assertDatabaseCount('invoices', 1); + + $sync->updateCustomerDomain($domain, $customer->id, '2027-07-21'); + $this->assertSame('2027-07-21', $service->fresh()->expires_at?->toDateString()); + + $sync->syncCustomers($domain, []); + + $this->assertSoftDeleted('services', ['id' => $service->id]); + $this->assertSame(3, (int) $service->invoice->fresh()->status); + $this->assertDatabaseMissing('dns_domain_user', [ + 'domain_id' => $domain->id, + 'user_id' => $customer->id, + ]); + } +} diff --git a/tests/Feature/Api/Admin/DnsManager/SyncDomainsFromPleskTest.php b/tests/Feature/Api/Admin/DnsManager/SyncDomainsFromPleskTest.php new file mode 100644 index 0000000..20c2b64 --- /dev/null +++ b/tests/Feature/Api/Admin/DnsManager/SyncDomainsFromPleskTest.php @@ -0,0 +1,103 @@ +serverId = DB::table('dns_manager_servers')->insertGetId(['ip_address' => '10.0.0.1', 'password' => 'x']); + $this->otherServerId = DB::table('dns_manager_servers')->insertGetId(['ip_address' => '10.0.0.2', 'password' => 'x']); + } + + private function runJob(array|false $pleskDomains): void + { + $plesk = Mockery::mock(PleskDomainService::class); + $plesk->shouldReceive('listDomains')->once()->andReturn($pleskDomains); + $plesk->shouldReceive('getLastError')->andReturn('boom'); + + $server = Mockery::mock(ServerService::class); + $server->shouldReceive('addServerById')->andReturn(true); + $server->shouldReceive('listRecords')->andReturn(['error' => false, 'data' => []]); + + $this->app->instance(PleskDomainService::class, $plesk); + $this->app->instance(ServerService::class, $server); + + $this->app->call([new SyncDnsFromPlesk($this->serverId), 'handle']); + } + + public function test_imports_new_domains_and_links_pending_ones_on_selected_server(): void + { + Event::fake([DnsSyncCompleted::class]); + $pending = DnsDomain::create(['domain_name' => 'Pending.vn', 'dns_manager_server_id' => $this->serverId, 'is_created' => 0]); + $otherServer = DnsDomain::create(['domain_name' => 'other.vn', 'dns_manager_server_id' => $this->otherServerId, 'is_created' => 0]); + $cancelled = DnsDomain::create(['domain_name' => 'cancelled.vn', 'dns_manager_server_id' => $this->serverId, 'is_created' => DnsDomain::STATUS_CANCELLED]); + + $this->runJob([ + ['id' => 1, 'name' => 'new.vn', 'base_domain_id' => 0], + ['id' => 2, 'name' => 'pending.vn', 'base_domain_id' => 0], + ['id' => 3, 'name' => 'other.vn', 'base_domain_id' => 0], + ['id' => 4, 'name' => 'cancelled.vn', 'base_domain_id' => 0], + ['id' => 5, 'name' => 'sub.new.vn', 'base_domain_id' => 1], + ]); + + $this->assertDatabaseHas('dns_manager_domain', ['domain_name' => 'new.vn', 'plesk_id' => 1, 'dns_manager_server_id' => $this->serverId, 'is_created' => 1]); + $this->assertDatabaseHas('dns_manager_domain', ['id' => $pending->id, 'plesk_id' => 2, 'is_created' => 1]); + $this->assertDatabaseHas('dns_manager_domain', ['id' => $otherServer->id, 'plesk_id' => null]); + $this->assertDatabaseHas('dns_manager_domain', ['id' => $cancelled->id, 'plesk_id' => null, 'is_created' => DnsDomain::STATUS_CANCELLED]); + $this->assertDatabaseMissing('dns_manager_domain', ['domain_name' => 'sub.new.vn']); + Event::assertDispatched(DnsSyncCompleted::class, fn ($e) => $e->imported === 2 && $e->failedDomains === []); + } + + public function test_reports_failure_when_plesk_domain_list_unavailable(): void + { + Event::fake([DnsSyncCompleted::class]); + + $this->runJob(false); + + $this->assertDatabaseCount('dns_manager_domain', 0); + Event::assertDispatched(DnsSyncCompleted::class, fn ($e) => $e->failedDomains !== []); + } + + public function test_endpoint_queues_sync_for_selected_server(): void + { + Queue::fake(); + Role::firstOrCreate(['name' => 'Full Administrator', 'guard_name' => 'admin']); + $admin = User::factory()->create(); + $admin->assignRole('Full Administrator'); + + $this->actingAs($admin, 'admin') + ->postJson('/api/v1/administrator/domains/sync-dns', ['dns_manager_server_id' => 999999]) + ->assertStatus(422); + + $this->actingAs($admin, 'admin') + ->postJson('/api/v1/administrator/domains/sync-dns', ['dns_manager_server_id' => $this->serverId]) + ->assertOk(); + + Queue::assertPushed(SyncDnsFromPlesk::class, 1); + } +} diff --git a/tests/Feature/Api/Admin/Product/ProductCategorySortOrderTest.php b/tests/Feature/Api/Admin/Product/ProductCategorySortOrderTest.php new file mode 100644 index 0000000..e6222ee --- /dev/null +++ b/tests/Feature/Api/Admin/Product/ProductCategorySortOrderTest.php @@ -0,0 +1,32 @@ + 'Full Administrator', 'guard_name' => 'admin']); + $admin = User::factory()->create(); + $admin->assignRole('Full Administrator'); + $url = '/api/v1/administrator/product-categories'; + + $this->actingAs($admin, 'admin') + ->postJson($url, ['name' => 'Wordpress Hosting', 'sort_order' => null]) + ->assertStatus(422) + ->assertJsonPath('errors.sort_order.0', 'Vui lòng nhập thứ tự hiển thị (sort_order).'); + + $this->actingAs($admin, 'admin') + ->postJson($url, ['name' => 'Wordpress Hosting', 'sort_order' => 3]) + ->assertCreated(); + } +} diff --git a/tests/Feature/Api/Client/ClientServiceListTest.php b/tests/Feature/Api/Client/ClientServiceListTest.php new file mode 100644 index 0000000..82bfbc5 --- /dev/null +++ b/tests/Feature/Api/Client/ClientServiceListTest.php @@ -0,0 +1,94 @@ +create(); + $otherCustomer = Customer::factory()->create(); + + $vps = Service::create([ + 'customer_id' => $customer->id, + 'type' => 'vps', + 'server_name' => 'vps.example.vn', + 'status' => 'active', + ]); + $hosting = Service::create([ + 'customer_id' => $customer->id, + 'type' => 'hosting', + 'server_name' => 'hosting.example.vn', + 'status' => 'cancelled', + ]); + $domain = Service::create([ + 'customer_id' => $customer->id, + 'type' => 'domain', + 'server_name' => 'example.vn', + 'status' => 'active', + ]); + $dnsServerId = DB::table('dns_manager_servers')->insertGetId([ + 'ip_address' => '10.0.0.1', + 'password' => 'x', + ]); + $dnsDomain = DnsDomain::create([ + 'domain_name' => 'example.vn', + 'dns_manager_server_id' => $dnsServerId, + 'is_created' => DnsDomain::STATUS_CREATED, + ]); + $dnsDomain->customer()->attach($customer->id); + DnsManagerNS::create([ + 'dns_manager_domain_id' => $dnsDomain->id, + 'type' => 'A', + 'type_valid' => 'A', + 'name' => 'example.vn', + 'value' => '10.0.0.2', + 'ttl' => 300, + ]); + Service::create([ + 'customer_id' => $otherCustomer->id, + 'type' => 'vps', + 'server_name' => 'private.example.vn', + 'status' => 'active', + ]); + + $response = $this->actingAs($customer, 'client') + ->withHeaders(['x-access-token' => $this->buildClientAccessToken()]) + ->getJson('/v1/client/client-service') + ->assertOk() + ->assertJsonPath('error', false) + ->assertJsonCount(3, 'data.items'); + + $items = collect($response->json('data.items'))->keyBy('uuid'); + + $this->assertSame('vps', $items[$vps->uuid]['type']); + $this->assertSame('hosting', $items[$hosting->uuid]['type']); + $this->assertSame(1, $items[$domain->uuid]['dns_records_count']); + $this->assertFalse($items->contains('server_name', 'private.example.vn')); + + $this->assertSame(['os', 'specs'], array_values(array_intersect( + array_keys($items[$vps->uuid]), + ['os', 'specs', 'service_plan', 'dns_records_count'], + ))); + $this->assertSame(['service_plan'], array_values(array_intersect( + array_keys($items[$hosting->uuid]), + ['os', 'specs', 'service_plan', 'dns_records_count'], + ))); + $this->assertSame(['dns_records_count'], array_values(array_intersect( + array_keys($items[$domain->uuid]), + ['os', 'specs', 'service_plan', 'dns_records_count'], + ))); + } +} diff --git a/tests/Feature/Api/Service/RenewServiceTest.php b/tests/Feature/Api/Service/RenewServiceTest.php index afa0b0a..617da21 100644 --- a/tests/Feature/Api/Service/RenewServiceTest.php +++ b/tests/Feature/Api/Service/RenewServiceTest.php @@ -5,8 +5,6 @@ declare(strict_types=1); namespace Tests\Feature\Api\Service; use App\Enums\Invoice\InvoiceStatus; -use App\Models\Addon; -use App\Enums\Order\OrderStatus; use App\Models\Customer; use App\Models\Invoice; use App\Models\Order; @@ -14,7 +12,6 @@ use App\Models\OrderItem; use App\Models\Product; use App\Models\ProductPricings; use App\Models\Service; -use App\Models\ServiceAddon; use Illuminate\Foundation\Testing\RefreshDatabase; use Tests\TestCase; @@ -23,330 +20,85 @@ class RenewServiceTest extends TestCase use RefreshDatabase; private Customer $customer; - private Product $product; + private Service $service; + private Order $order; protected function setUp(): void { parent::setUp(); - $this->customer = Customer::factory()->create(); - $this->product = Product::factory()->create([ - 'product_type' => 'hosting', - 'is_active' => true, - ]); - } + $this->customer = Customer::factory()->create(['total' => 100_000]); + $product = Product::factory()->create(['product_type' => 'hosting', 'is_active' => true]); + ProductPricings::create(['product_id' => $product->id, 'billing_cycle' => 'monthly', 'price' => 15_000, 'is_active' => true]); - private function actingAsCustomer(): static - { - return $this->actingAs($this->customer, 'client') - ->withHeaders(['x-access-token' => $this->buildClientAccessToken()]); - } - - /** Encrypts the shared secret the same way ClientAccessTokenCheck expects, for test requests. */ - - private function createServiceWithOldPrice(int $oldPrice): Service - { - $order = Order::create([ - 'order_num' => 'ORD-' . uniqid(), + $this->order = Order::create([ + 'order_num' => 'ORD-RENEW-TEST', 'customer_id' => $this->customer->id, - 'sub_total' => $oldPrice, - 'total' => $oldPrice, - 'status' => OrderStatus::ACTIVE->value, + 'total' => 15_000, + 'status' => 1, ]); $orderItem = OrderItem::create([ - 'order_id' => $order->id, - 'product_id' => $this->product->id, + 'order_id' => $this->order->id, + 'product_id' => $product->id, 'billing_cycle' => 'monthly', 'months' => 1, 'quantity' => 1, - 'price' => $oldPrice, + 'price' => 15_000, + 'addons_meta' => [], ]); - return Service::create([ + $this->service = Service::create([ 'customer_id' => $this->customer->id, - 'product_id' => $this->product->id, + 'product_id' => $product->id, 'order_item_id' => $orderItem->id, + 'type' => 'hosting', 'status' => 'active', - 'server_name' => 'test-server', + 'started_at' => now()->subMonth(), + 'expires_at' => now()->addDays(5), ]); } - private function createServiceWithCycle(string $billingCycle, int $cycleMonths, int $oldPrice): Service + private function renew(int $months) { - $order = Order::create([ - 'order_num' => 'ORD-' . uniqid(), - 'customer_id' => $this->customer->id, - 'sub_total' => $oldPrice, - 'total' => $oldPrice, - 'status' => OrderStatus::ACTIVE->value, - ]); - - $orderItem = OrderItem::create([ - 'order_id' => $order->id, - 'product_id' => $this->product->id, - 'billing_cycle' => $billingCycle, - 'months' => $cycleMonths, - 'quantity' => 1, - 'price' => $oldPrice, - ]); - - return Service::create([ - 'customer_id' => $this->customer->id, - 'product_id' => $this->product->id, - 'order_item_id' => $orderItem->id, - 'status' => 'active', - 'server_name' => 'test-server', - ]); + return $this->actingAs($this->customer, 'client') + ->withHeaders(['x-access-token' => $this->buildClientAccessToken()]) + ->postJson("/v1/client/dich-vu/{$this->service->id}/renew", ['months' => $months]); } - public function test_renew_annual_service_for_exactly_one_cycle_charges_the_annual_price(): void + public function test_renew_charges_balance_and_extends_expiry_on_the_same_order(): void { - $service = $this->createServiceWithCycle('annually', 12, 1_800_000); + $expiresAt = $this->service->expires_at->copy(); - ProductPricings::create([ - 'product_id' => $this->product->id, - 'billing_cycle' => 'annually', - 'price' => 1_800_000, - 'is_active' => true, - ]); + $this->renew(3)->assertOk()->assertJsonPath('success', true); - $response = $this->actingAsCustomer()->postJson( - "/v1/client/dich-vu/{$service->id}/renew", - ['months' => 12], - ); - - $response->assertOk(); - $response->assertJsonPath('data.total', 1_800_000); - $this->assertDatabaseHas('invoices', [ - 'id' => $response->json('data.invoice_id'), - 'total' => 1_800_000, - ]); + $invoice = Invoice::where('invoice_type', 2)->sole(); + $this->assertSame($this->order->id, (int) $invoice->order_id); + $this->assertSame(InvoiceStatus::PAID->value, (int) $invoice->status); + $this->assertSame(45_000, (int) $invoice->total); + $this->assertSame(55_000, (int) $this->customer->fresh()->total); + $this->assertSame(1, Order::count()); + $this->assertTrue($this->service->fresh()->expires_at->isSameDay($expiresAt->addMonths(3))); + $this->assertDatabaseHas('transactions', ['customer_id' => $this->customer->id, 'amount' => 45_000]); } - public function test_renew_annual_service_with_months_not_matching_the_cycle_is_rejected(): void + public function test_insufficient_balance_keeps_unpaid_invoice_and_does_not_extend(): void { - $service = $this->createServiceWithCycle('annually', 12, 1_800_000); + $this->customer->update(['total' => 10_000]); + $expiresAt = $this->service->expires_at->copy(); - ProductPricings::create([ - 'product_id' => $this->product->id, - 'billing_cycle' => 'annually', - 'price' => 1_800_000, - 'is_active' => true, - ]); + $this->renew(1)->assertStatus(422)->assertJsonPath('data.status', InvoiceStatus::UNPAID->value); - $response = $this->actingAsCustomer()->postJson( - "/v1/client/dich-vu/{$service->id}/renew", - ['months' => 1], - ); + $this->assertSame(10_000, (int) $this->customer->fresh()->total); + $this->assertTrue($this->service->fresh()->expires_at->equalTo($expiresAt)); - $response->assertStatus(422); - $this->assertSame(0, Invoice::where('customer_id', $this->customer->id)->count()); - } + $this->customer->update(['total' => 100_000]); + $this->renew(2)->assertOk(); - public function test_renew_charges_the_recurring_addon_price_on_top_of_the_service_price(): void - { - $service = $this->createServiceWithOldPrice(90_000); - - $addon = Addon::create([ - 'type' => $this->product->product_type, - 'code' => 'ram', - 'name' => 'RAM', - 'price' => 50_000, - 'min' => 1, - 'max' => 64, - 'step' => 1, - 'status' => true, - ]); - - $service->orderItem->update(['addons_meta' => [[ - 'addon_id' => $addon->id, - 'code' => 'ram', - 'name' => 'RAM', - 'qty' => 2, - 'price' => 40_000, - 'subtotal' => 80_000, - ]]]); - - ServiceAddon::create([ - 'service_id' => $service->id, - 'addon_type' => 'ram', - 'addon_value' => '6144', - 'price' => 50_000, - ]); - - ProductPricings::create([ - 'product_id' => $this->product->id, - 'billing_cycle' => 'monthly', - 'price' => 100_000, - 'is_active' => true, - ]); - - $response = $this->actingAsCustomer()->postJson( - "/v1/client/dich-vu/{$service->id}/renew", - ['months' => 1], - ); - - $response->assertOk(); - $response->assertJsonPath('data.total', 200_000); - $invoiceId = $response->json('data.invoice_id'); - $this->assertDatabaseHas('invoice_items', [ - 'invoice_id' => $invoiceId, - 'type' => 'renewal', - 'unit_price' => 100_000, - ]); - $this->assertDatabaseHas('invoice_items', [ - 'invoice_id' => $invoiceId, - 'type' => 'addon', - 'quantity' => 2, - 'total_price' => 100_000, - ]); - } - - public function test_renew_uses_current_catalog_price_not_the_original_order_price(): void - { - $service = $this->createServiceWithOldPrice(90_000); - - // Giá sản phẩm hiện tại đã tăng lên 100.000 kể từ lúc khách đặt mua. - ProductPricings::create([ - 'product_id' => $this->product->id, - 'billing_cycle' => 'monthly', - 'price' => 100_000, - 'is_active' => true, - ]); - - $response = $this->actingAsCustomer()->postJson( - "/v1/client/dich-vu/{$service->id}/renew", - ['months' => 2], - ); - - $response->assertOk(); - $response->assertJsonPath('data.total', 200_000); - - $this->assertDatabaseHas('invoices', [ - 'id' => $response->json('data.invoice_id'), - 'total' => 200_000, - ]); - } - - public function test_renew_fails_when_product_pricing_is_no_longer_active(): void - { - $service = $this->createServiceWithOldPrice(90_000); - - ProductPricings::create([ - 'product_id' => $this->product->id, - 'billing_cycle' => 'monthly', - 'price' => 100_000, - 'is_active' => false, - ]); - - $response = $this->actingAsCustomer()->postJson( - "/v1/client/dich-vu/{$service->id}/renew", - ['months' => 1], - ); - - $response->assertStatus(400); - $this->assertSame(0, Invoice::where('customer_id', $this->customer->id)->count()); - } - - public function test_renew_reuses_original_order_instead_of_creating_a_new_one(): void - { - $service = $this->createServiceWithOldPrice(90_000); - $originalOrderId = $service->orderItem->order_id; - - ProductPricings::create([ - 'product_id' => $this->product->id, - 'billing_cycle' => 'monthly', - 'price' => 100_000, - 'is_active' => true, - ]); - - $response = $this->actingAsCustomer()->postJson( - "/v1/client/dich-vu/{$service->id}/renew", - ['months' => 1], - ); - - $response->assertOk(); - $response->assertJsonPath('data.order_id', $originalOrderId); - - // Order 1:N Invoice — no second Order row is ever created for a renewal. - $this->assertSame(1, Order::where('customer_id', $this->customer->id)->count()); - $this->assertDatabaseHas('invoices', [ - 'id' => $response->json('data.invoice_id'), - 'order_id' => $originalOrderId, - 'invoice_type' => 2, - ]); - } - - public function test_renewal_invoice_has_a_billing_period_and_is_not_duplicated_for_the_same_period(): void - { - $service = $this->createServiceWithOldPrice(90_000); - - ProductPricings::create([ - 'product_id' => $this->product->id, - 'billing_cycle' => 'monthly', - 'price' => 100_000, - 'is_active' => true, - ]); - - $response = $this->actingAsCustomer()->postJson( - "/v1/client/dich-vu/{$service->id}/renew", - ['months' => 1], - ); - $response->assertOk(); - - $invoiceId = $response->json('data.invoice_id'); - $this->assertDatabaseHas('invoices', ['id' => $invoiceId]); - $invoice = Invoice::find($invoiceId); - $this->assertNotNull($invoice->billing_period_start); - $this->assertNotNull($invoice->billing_period_end); - - // Calling renew again while the invoice is still open must return the SAME - // invoice, not create a duplicate for the same billing period. - $second = $this->actingAsCustomer()->postJson( - "/v1/client/dich-vu/{$service->id}/renew", - ['months' => 1], - ); - $second->assertOk(); - $second->assertJsonPath('data.invoice_id', $invoiceId); - $this->assertSame(1, Invoice::where('customer_id', $this->customer->id)->where('invoice_type', 2)->count()); - } - - /** - * Đúng flow đề bài mục 6: invoice gia hạn quá due_date -> OVERDUE, KHÔNG tạo invoice mới. - * Verify khách bấm "Gia hạn" lại LÚC invoice đang OVERDUE (chưa qua billing cycle tiếp theo) - * vẫn nhận về đúng invoice cũ, không bị tạo trùng. - */ - public function test_renew_while_existing_invoice_is_overdue_returns_the_same_invoice(): void - { - $service = $this->createServiceWithOldPrice(90_000); - - ProductPricings::create([ - 'product_id' => $this->product->id, - 'billing_cycle' => 'monthly', - 'price' => 100_000, - 'is_active' => true, - ]); - - $first = $this->actingAsCustomer()->postJson( - "/v1/client/dich-vu/{$service->id}/renew", - ['months' => 1], - ); - $first->assertOk(); - $invoiceId = $first->json('data.invoice_id'); - - // Invoice quá due_date -> cron invoices:check-overdue chuyển sang OVERDUE (flow thật). - Invoice::whereKey($invoiceId)->update(['due_date' => now()->subDay()->toDateString()]); - $this->artisan('invoices:check-overdue')->assertExitCode(0); - $this->assertSame(InvoiceStatus::OVERDUE->value, (int) Invoice::find($invoiceId)->status); - - // Khách bấm "Gia hạn" lại trong khi invoice đang OVERDUE — không được tạo invoice #2. - $second = $this->actingAsCustomer()->postJson( - "/v1/client/dich-vu/{$service->id}/renew", - ['months' => 1], - ); - $second->assertOk(); - $second->assertJsonPath('data.invoice_id', $invoiceId); - $this->assertSame(1, Invoice::where('customer_id', $this->customer->id)->where('invoice_type', 2)->count()); + $invoice = Invoice::where('invoice_type', 2)->sole(); + $this->assertSame(InvoiceStatus::PAID->value, (int) $invoice->status); + $this->assertSame(30_000, (int) $invoice->total); + $this->assertSame(1, $invoice->invoiceItems()->count()); + $this->assertSame(70_000, (int) $this->customer->fresh()->total); } } diff --git a/tests/Feature/Api/Transaction/ManualDepositDraftExpiryTest.php b/tests/Feature/Api/Transaction/ManualDepositDraftExpiryTest.php new file mode 100644 index 0000000..2e7f0a0 --- /dev/null +++ b/tests/Feature/Api/Transaction/ManualDepositDraftExpiryTest.php @@ -0,0 +1,131 @@ +actingAs($customer, 'client') + ->withHeaders(['x-access-token' => $this->buildClientAccessToken()]); + } + + private function prepare(Customer $customer, int $amount = 500000): array + { + return $this->actingAsCustomer($customer) + ->postJson('/v1/transaction/manual-deposit/prepare', ['amount' => $amount]) + ->assertOk() + ->json('data'); + } + + public function test_prepare_returns_expiry_and_show_restores_draft(): void + { + $customer = Customer::factory()->create(['total_pending' => 0]); + + $draft = $this->prepare($customer); + + $this->assertSame(500000, $draft['amount']); + $this->assertEqualsWithDelta(now()->addMinutes(30)->timestamp, strtotime($draft['expires_at']), 5); + + $this->actingAsCustomer($customer) + ->getJson("/v1/transaction/manual-deposit/{$draft['uuid']}") + ->assertOk() + ->assertJsonPath('data.public_code', $draft['public_code']) + ->assertJsonPath('data.amount', 500000); + } + + public function test_show_hides_draft_of_another_customer(): void + { + $owner = Customer::factory()->create(); + $other = Customer::factory()->create(); + + $draft = $this->prepare($owner); + + $this->actingAsCustomer($other) + ->getJson("/v1/transaction/manual-deposit/{$draft['uuid']}") + ->assertNotFound(); + } + + public function test_expired_draft_is_discarded_on_show_and_rejected_on_confirm(): void + { + Storage::fake('local'); + $customer = Customer::factory()->create(['total_pending' => 0]); + + $first = $this->prepare($customer); + $this->travel(31)->minutes(); + + $this->actingAsCustomer($customer) + ->getJson("/v1/transaction/manual-deposit/{$first['uuid']}") + ->assertStatus(410); + $this->assertNull(Transaction::withTrashed()->where('uuid', $first['uuid'])->first()); + $this->assertSame(0, (int) $customer->fresh()->total_pending); + + $second = $this->prepare($customer); + $this->travel(31)->minutes(); + $path = UploadedFile::fake()->image('proof.jpg')->store('deposit-proofs/' . $customer->id, 'local'); + + $this->actingAsCustomer($customer) + ->postJson('/v1/transaction/manual-deposit/confirm', ['uuid' => $second['uuid'], 'proof_image' => $path]) + ->assertStatus(422); + $this->assertNull(Transaction::where('uuid', $second['uuid'])->value('proof_image')); + } + + public function test_purge_command_removes_only_expired_drafts(): void + { + $customer = Customer::factory()->create(['total_pending' => 0]); + + $expired = $this->prepare($customer, 300000); + $this->travel(31)->minutes(); + $other = Customer::factory()->create(['total_pending' => 0]); + $fresh = $this->prepare($other, 200000); + + $submitted = Transaction::create([ + 'amount' => 100000, + 'customer_id' => $customer->id, + 'type' => 'manual', + 'status' => TransactionStatus::PENDING->value, + 'proof_image' => 'deposit-proofs/x.jpg', + 'trans' => 'MANTEST', + ]); + + $this->artisan('deposits:purge-expired-drafts')->assertSuccessful(); + + $this->assertNull(Transaction::withTrashed()->where('uuid', $expired['uuid'])->first()); + $this->assertNotNull(Transaction::where('uuid', $fresh['uuid'])->first()); + $this->assertNotNull($submitted->fresh()); + $this->assertSame(0, (int) $customer->fresh()->total_pending); + $this->assertSame(0, (int) $other->fresh()->total_pending); + } + + public function test_total_pending_only_counts_amount_after_proof_is_submitted(): void + { + Storage::fake('local'); + $customer = Customer::factory()->create(['total_pending' => 0]); + + $draft = $this->prepare($customer, 700000); + $this->assertSame(0, (int) $customer->fresh()->total_pending); + + $path = $this->actingAsCustomer($customer) + ->post('/v1/transaction/manual-deposit/upload-proof', ['file' => UploadedFile::fake()->image('proof.jpg')]) + ->assertOk() + ->json('path'); + $this->assertSame(0, (int) $customer->fresh()->total_pending); + + $this->actingAsCustomer($customer) + ->postJson('/v1/transaction/manual-deposit/confirm', ['uuid' => $draft['uuid'], 'proof_image' => $path]) + ->assertOk(); + $this->assertSame(700000, (int) $customer->fresh()->total_pending); + } +} diff --git a/tests/Feature/Payment/AdminManualDepositActionsTest.php b/tests/Feature/Payment/AdminManualDepositActionsTest.php new file mode 100644 index 0000000..1b14ec4 --- /dev/null +++ b/tests/Feature/Payment/AdminManualDepositActionsTest.php @@ -0,0 +1,93 @@ + (string) Str::uuid(), + 'trans' => 'MANTEST' . Str::random(6), + 'amount' => $amount, + 'customer_id' => $customer->id, + 'type' => 'manual', + 'status' => $status->value, + 'proof_image' => $proof, + ]); + } + + public function test_draft_without_proof_can_be_rejected_and_approved_without_touching_pending(): void + { + Mail::fake(); + $customer = Customer::factory()->create(['total' => 0, 'total_pending' => 300_000]); + $controller = app(AdminTransactionController::class); + + $rejected = $this->deposit($customer, 5_000_000, null); + $this->assertSame(200, $controller->reject($rejected->uuid)->getStatusCode()); + $this->assertSame(TransactionStatus::FAILED->value, (int) $rejected->fresh()->status); + + $approved = $this->deposit($customer, 1_000_000, null); + $this->assertSame(200, $controller->approve(new Request(), $approved->uuid)->getStatusCode()); + + $customer->refresh(); + $this->assertSame(300_000, (int) $customer->total_pending); + $this->assertSame(1_000_000, (int) $customer->total); + } + + public function test_signed_proof_image_url_serves_the_file(): void + { + Storage::fake('local'); + $customer = Customer::factory()->create(); + $path = UploadedFile::fake()->image('proof.png')->store('deposit-proofs/' . $customer->id, 'local'); + $transaction = $this->deposit($customer, 300_000, $path); + + $this->get($transaction->proof_image_urls[0])->assertOk(); + $this->get(strtok($transaction->proof_image_urls[0], '?'))->assertForbidden(); + } + + public function test_deleting_submitted_pending_deposit_releases_pending_amount(): void + { + $customer = Customer::factory()->create(['total' => 0, 'total_pending' => 300_000]); + $transaction = $this->deposit($customer, 300_000, 'deposit-proofs/a.jpg'); + + $response = app(AdminTransactionController::class)->destroy($transaction->uuid); + + $this->assertSame(200, $response->getStatusCode()); + $this->assertSoftDeleted($transaction); + $this->assertSame(0, (int) $customer->fresh()->total_pending); + } + + public function test_deleting_draft_or_completed_deposit_keeps_balances(): void + { + $customer = Customer::factory()->create(['total' => 2_000_000, 'total_pending' => 300_000]); + $controller = app(AdminTransactionController::class); + + $draft = $this->deposit($customer, 500_000, null); + $completed = $this->deposit($customer, 2_000_000, 'deposit-proofs/b.jpg', TransactionStatus::COMPLETED); + + $this->assertSame(200, $controller->destroy($draft->uuid)->getStatusCode()); + $this->assertSame(200, $controller->destroy($completed->uuid)->getStatusCode()); + $this->assertSame(404, $controller->destroy($completed->uuid)->getStatusCode()); + + $customer->refresh(); + $this->assertSame(300_000, (int) $customer->total_pending); + $this->assertSame(2_000_000, (int) $customer->total); + } +} diff --git a/tests/Feature/Payment/AdminTransactionApproveTest.php b/tests/Feature/Payment/AdminTransactionApproveTest.php index 4799a16..f95bbc8 100644 --- a/tests/Feature/Payment/AdminTransactionApproveTest.php +++ b/tests/Feature/Payment/AdminTransactionApproveTest.php @@ -30,6 +30,7 @@ class AdminTransactionApproveTest extends TestCase 'customer_id' => $customer->id, 'type' => 'manual', 'status' => TransactionStatus::PENDING->value, + 'proof_image' => 'deposit-proofs/test.jpg', ]); $controller = app(AdminTransactionController::class); @@ -61,6 +62,7 @@ class AdminTransactionApproveTest extends TestCase 'customer_id' => $customer->id, 'type' => 'manual', 'status' => TransactionStatus::PENDING->value, + 'proof_image' => 'deposit-proofs/test.jpg', ]); $request = Request::create('/', 'POST', ['received_amount' => 4_000_000]); @@ -92,6 +94,7 @@ class AdminTransactionApproveTest extends TestCase 'customer_id' => $customer->id, 'type' => 'manual', 'status' => TransactionStatus::PENDING->value, + 'proof_image' => 'deposit-proofs/test.jpg', ]); app(AdminTransactionController::class)->approve(new Request(), $transaction->uuid);