where('tenant_id', $tenantId) ->when($actor->access_level === UserAccessLevel::CUSTOMER, fn ($q) => $q->whereHas('agent', fn ($x) => $x->where('user_id', $actor->id))) ->when($filters['search'] ?? null, fn ($q, $search) => $q->where('sale_number', 'ilike', "%{$search}%")) ->when($filters['payment_status'] ?? null, fn ($q, $value) => $q->where('payment_status', $value)) ->latest('id')->paginate($filters['per_page'] ?? 10); } public function saleDetail(VoucherSale $sale, User $actor, ?int $tenantId): VoucherSale { $this->authorizeTenant($sale->tenant_id, $tenantId, $actor); if ($actor->access_level === UserAccessLevel::CUSTOMER && $sale->agent?->user_id !== $actor->id) { abort(403); } $sale->load(['agent.user:id,name,username', 'packageProfile', 'vouchers.mikrotik:id,name']); $sale->vouchers->each(fn (HotspotVoucher $voucher) => $voucher->setAttribute('access_password', $voucher->password)); return $sale; } public function createSale(array $data, User $actor, ?int $tenantId): VoucherSale { if (! $tenantId) { throw ValidationException::withMessages(['tenant_id' => 'Tenant aktif wajib tersedia.']); } $package = NasPackageProfile::whereKey($data['nas_package_profile_id'])->where('tenant_id', $tenantId) ->where('service_type', 'hotspot')->where('status', 'active')->firstOrFail(); if (! empty($data['nas_mikrotik_id']) && ! NasMikrotik::whereKey($data['nas_mikrotik_id'])->where('tenant_id', $tenantId)->exists()) { throw ValidationException::withMessages(['nas_mikrotik_id' => 'Mikrotik tidak valid untuk tenant aktif.']); } return DB::transaction(function () use ($data, $actor, $tenantId, $package) { $agent = $actor->access_level === UserAccessLevel::CUSTOMER ? VoucherAgent::with('user')->where('tenant_id', $tenantId)->where('user_id', $actor->id)->where('status', 'active')->lockForUpdate()->first() : null; if ($actor->access_level === UserAccessLevel::CUSTOMER && ! $agent) { throw ValidationException::withMessages(['agent' => 'Akun belum terdaftar sebagai agen voucher aktif.']); } $quantity = (int) $data['quantity']; $gross = (float) $package->price * $quantity; $benefitPercent = $agent?->price_mode === 'commission' ? (float) $agent->commission_percent : (float) ($agent?->discount_percent ?? 0); $discount = round($gross * $benefitPercent / 100, 2); $total = $gross - $discount; $saleUuid = (string) Str::uuid(); if ($agent?->payment_type === 'prepaid') { $this->wallets->chargeUser($actor, $tenantId, $total, "voucher-sale:{$saleUuid}", "Pembelian {$quantity} voucher {$package->name}"); } if ($agent?->payment_type === 'postpaid') { if ((float) $agent->outstanding_balance + $total > (float) $agent->credit_limit) { throw ValidationException::withMessages(['credit_limit' => 'Limit kredit agen tidak mencukupi.']); } $agent->increment('outstanding_balance', $total); } $sale = VoucherSale::create([ 'uuid' => $saleUuid, 'sale_number' => $this->nextSaleNumber($tenantId), 'tenant_id' => $tenantId, 'voucher_agent_id' => $agent?->id, 'nas_package_profile_id' => $package->id, 'created_by' => $actor->id, 'source' => 'dashboard', 'quantity' => $quantity, 'unit_price' => $package->price, 'discount_amount' => $discount, 'total_amount' => $total, 'payment_status' => $agent?->payment_type === 'postpaid' ? 'credit' : 'paid', 'status' => 'completed', 'pricing_snapshot' => [ 'agent_type' => $agent?->payment_type, 'price_mode' => $agent?->price_mode, 'discount_percent' => $agent?->discount_percent, 'commission_percent' => $agent?->commission_percent, 'agent_benefit_amount' => $discount, ], 'notes' => $data['notes'] ?? null, ]); for ($index = 0; $index < $quantity; $index++) { $username = $this->uniqueVoucherCode($tenantId); $password = Str::upper(Str::random(6)); $voucher = HotspotVoucher::create([ 'uuid' => Str::uuid(), 'tenant_id' => $tenantId, 'voucher_sale_id' => $sale->id, 'nas_package_profile_id' => $package->id, 'nas_mikrotik_id' => $data['nas_mikrotik_id'] ?? null, 'username' => $username, 'password' => $password, 'status' => 'generated', 'sync_status' => 'pending', 'profile_snapshot' => ['name' => $package->name, 'external_profile_name' => $package->external_profile_name, 'session_timeout' => $package->session_timeout], ]); $voucher->setAttribute('access_password', $password); } return $this->saleDetail($sale, $actor, $tenantId); }); } public function agents(array $filters, User $actor, ?int $tenantId) { return VoucherAgent::with(['user:id,name,username,email,whatsapp_number']) ->when(! $actor->isMasterAdmin(), fn ($q) => $q->where('tenant_id', $tenantId)) ->when($actor->isMasterAdmin() && $tenantId, fn ($q) => $q->where('tenant_id', $tenantId)) ->when($filters['search'] ?? null, fn ($q, $search) => $q->where(fn ($x) => $x->where('agent_code', 'ilike', "%{$search}%")->orWhereHas('user', fn ($u) => $u->where('name', 'ilike', "%{$search}%")))) ->latest('id')->paginate($filters['per_page'] ?? 10); } public function saveAgent(array $data, User $actor, ?int $tenantId, ?VoucherAgent $agent = null): VoucherAgent { $effectiveTenant = $actor->isMasterAdmin() ? ($data['tenant_id'] ?? $tenantId ?? $agent?->tenant_id) : $tenantId; if (! $effectiveTenant) { throw ValidationException::withMessages(['tenant_id' => 'Tenant wajib dipilih.']); } if ($agent) { $this->authorizeTenant($agent->tenant_id, $effectiveTenant, $actor); } $belongs = DB::table('user_tenants')->where('tenant_id', $effectiveTenant)->where('user_id', $data['user_id'])->exists(); if (! $belongs) { throw ValidationException::withMessages(['user_id' => 'User tidak terdaftar pada tenant tersebut.']); } $payload = [...$data, 'tenant_id' => $effectiveTenant, 'uuid' => $agent?->uuid ?? Str::uuid()]; $payload['agent_code'] = filled($data['agent_code'] ?? null) ? Str::upper($data['agent_code']) : $this->nextAgentCode($effectiveTenant); if ($data['payment_type'] === 'prepaid') { $payload['credit_limit'] = 0; } $model = $agent ?: new VoucherAgent; $model->fill($payload)->save(); return $model->load('user:id,name,username,email,whatsapp_number'); } public function options(string $scope, User $actor, ?int $tenantId): array { $data = []; if (in_array($scope, ['sales', 'login-page'], true)) { $data['packages'] = NasPackageProfile::where('tenant_id', $tenantId)->where('service_type', 'hotspot')->where('status', 'active')->orderBy('name')->get(); } if ($scope === 'sales') { $data['mikrotiks'] = NasMikrotik::where('tenant_id', $tenantId)->where('status', 'active')->orderBy('name')->get(['id', 'name', 'connection_type']); } if ($scope === 'agents') { $data['users'] = User::whereHas('userTenants', fn ($q) => $q->where('tenant_id', $tenantId))->orderBy('name')->get(['id', 'name', 'username', 'email']); } return $data; } public function loginPage(User $actor, ?int $tenantId): array { if (! $tenantId) { throw ValidationException::withMessages(['tenant_id' => 'Tenant aktif wajib tersedia.']); } $setting = VoucherLoginPageSetting::firstOrCreate(['tenant_id' => $tenantId], [ 'uuid' => Str::uuid(), 'public_key' => Str::random(64), 'enabled' => false, ]); return ['setting' => $setting->load('gatewayAccount.provider:id,code,name'), 'embed_script' => $this->embedScript($setting), 'dummy_sales' => $setting->enabled ? $this->dummySales() : []]; } public function saveLoginPage(array $data, User $actor, ?int $tenantId): array { $setting = VoucherLoginPageSetting::firstOrCreate(['tenant_id' => $tenantId], ['uuid' => Str::uuid(), 'public_key' => Str::random(64)]); $requestedPackages = collect($data['allowed_package_profile_ids'] ?? [])->map(fn ($id) => (int) $id)->unique(); $validPackageCount = NasPackageProfile::where('tenant_id', $tenantId)->where('service_type', 'hotspot')->whereIn('id', $requestedPackages)->count(); if ($validPackageCount !== $requestedPackages->count()) { throw ValidationException::withMessages(['allowed_package_profile_ids' => 'Terdapat profile paket yang bukan milik tenant aktif.']); } if ($data['enabled']) { $paymentSetting = TenantPaymentSetting::where('tenant_id', $tenantId)->whereIn('payment_mode', ['tenant_gateway', 'platform_gateway'])->first(); $accountId = $paymentSetting?->payment_mode === 'tenant_gateway' ? $paymentSetting?->tenant_gateway_account_id : $paymentSetting?->platform_gateway_account_id; $gateway = PaymentGatewayAccount::whereKey($accountId)->where('enabled', true)->first(); if (! $gateway) { throw ValidationException::withMessages(['enabled' => 'LoginPage Voucher hanya dapat diaktifkan jika tenant memiliki payment gateway aktif.']); } $data['payment_gateway_account_id'] = $gateway->id; } else { $data['payment_gateway_account_id'] = null; } $setting->update([...$data, 'enabled_at' => $data['enabled'] ? ($setting->enabled_at ?? now()) : null]); return $this->loginPage($actor, $tenantId); } private function embedScript(VoucherLoginPageSetting $setting): string { return '
'."\n".''; } private function dummySales(): array { return collect(range(1, 5))->map(fn ($i) => [ 'id' => $i, 'reference' => 'DEMO-'.now()->format('Ymd').'-'.str_pad((string) $i, 3, '0', STR_PAD_LEFT), 'package' => ['Hotspot 2 Jam', 'Hotspot Harian', 'Hotspot Mingguan'][$i % 3], 'buyer' => 'Customer Demo '.$i, 'amount' => 5000 * $i, 'status' => $i === 4 ? 'pending' : 'paid', 'created_at' => now()->subMinutes($i * 17)->toIso8601String(), ])->all(); } private function nextSaleNumber(int $tenantId): string { return 'VCR-'.$tenantId.'-'.now()->format('YmdHis').'-'.Str::upper(Str::random(4)); } private function nextAgentCode(int $tenantId): string { return 'AGT-'.$tenantId.'-'.str_pad((string) (VoucherAgent::where('tenant_id', $tenantId)->count() + 1), 4, '0', STR_PAD_LEFT); } private function uniqueVoucherCode(int $tenantId): string { do { $code = 'V'.Str::upper(Str::random(9)); } while (HotspotVoucher::where('tenant_id', $tenantId)->where('username', $code)->exists()); return $code; } private function authorizeTenant(int $modelTenantId, ?int $tenantId, User $actor): void { if (! $actor->isMasterAdmin()) { abort_unless($tenantId && $modelTenantId === $tenantId, 403); } } }