Files
services_core/app/Services/Voucher/VoucherService.php
T
2026-08-01 11:42:42 +07:00

242 lines
12 KiB
PHP

<?php
namespace App\Services\Voucher;
use App\Enums\UserAccessLevel;
use App\Models\HotspotVoucher;
use App\Models\NasMikrotik;
use App\Models\NasPackageProfile;
use App\Models\PaymentGatewayAccount;
use App\Models\TenantPaymentSetting;
use App\Models\User;
use App\Models\VoucherAgent;
use App\Models\VoucherLoginPageSetting;
use App\Models\VoucherSale;
use App\Services\Wallet\WalletService;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Str;
use Illuminate\Validation\ValidationException;
class VoucherService
{
public function __construct(protected WalletService $wallets) {}
public function sales(array $filters, User $actor, ?int $tenantId)
{
return VoucherSale::with(['agent.user:id,name,username', 'packageProfile:id,name,external_profile_name,session_timeout', 'creator:id,name'])
->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 '<div id="voucher-container"></div>'."\n".'<script src="https://manjapro.net/voucherpay/cl-style.js?i='.$setting->public_key.'"></script>';
}
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);
}
}
}