update api register

This commit is contained in:
Wian Drs
2026-08-01 11:42:42 +07:00
parent b0d08211ec
commit 75342dc7b7
55 changed files with 2254 additions and 35 deletions
+1 -1
View File
@@ -66,7 +66,7 @@ class AccessService
return true;
}
if (! $actor->isTenantOwner() || ! $tenantId || $target->isMasterAdmin()) {
if (! $actor->isTenantOwner($tenantId) || ! $tenantId || $target->isMasterAdmin()) {
return false;
}
@@ -0,0 +1,288 @@
<?php
namespace App\Services\Access;
use App\Models\Customer;
use App\Models\CustomerUserLink;
use App\Models\MenuGroup;
use App\Models\MenuList;
use App\Models\RoleApplication;
use App\Models\Tenant;
use App\Models\User;
use App\Models\UserMenuGroup;
use App\Models\UserTenant;
use App\Models\VoucherAgent;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Str;
use Illuminate\Validation\ValidationException;
class RoleApplicationService
{
public function mine(User $user)
{
return $user->roleApplications()->with(['tenant:id,tenant_code,tenant_name', 'reviewer:id,name'])->latest('id')->get();
}
public function submit(array $data, User $user): RoleApplication
{
$type = $data['application_type'];
if (RoleApplication::where('applicant_user_id', $user->id)->where('application_type', $type)->where('status', 'pending')->exists()) {
throw ValidationException::withMessages(['application_type' => 'Masih ada pengajuan sejenis yang menunggu persetujuan.']);
}
if ($type === 'tenant' && $user->userTenants()->where('role', 'tenant_owner')->exists()) {
throw ValidationException::withMessages(['application_type' => 'Satu user hanya boleh menjadi Tenant Owner pada satu tenant.']);
}
if ($type === 'agent' && VoucherAgent::where('user_id', $user->id)->where('tenant_id', $data['tenant_id'])->where('status', 'active')->exists()) {
throw ValidationException::withMessages(['tenant_id' => 'User sudah menjadi agen aktif pada tenant tersebut.']);
}
if ($type === 'staff' && $user->userTenants()->where('tenant_id', $data['tenant_id'])->where('role', 'tenant_owner')->exists()) {
throw ValidationException::withMessages(['tenant_id' => 'Tenant Owner tidak dapat mengajukan diri sebagai staff pada tenant miliknya.']);
}
return RoleApplication::create([
'uuid' => Str::uuid(), 'applicant_user_id' => $user->id,
'tenant_id' => in_array($type, ['agent', 'staff'], true) ? $data['tenant_id'] : null, 'application_type' => $type,
'status' => 'pending', 'payload' => collect($data)->except(['application_type', 'tenant_id'])->all(),
'submitted_at' => now(),
])->load('tenant:id,tenant_code,tenant_name');
}
public function reviewList(string $type, array $filters, User $reviewer, ?int $tenantId)
{
$this->authorizeReviewer($type, $reviewer, $tenantId);
return RoleApplication::with(['applicant:id,name,username,email,whatsapp_number', 'tenant:id,tenant_code,tenant_name', 'reviewer:id,name'])
->where('application_type', $type)
->when(in_array($type, ['agent', 'staff'], true) && ! $reviewer->isMasterAdmin(), fn ($q) => $q->where('tenant_id', $tenantId))
->when($filters['status'] ?? null, fn ($q, $status) => $q->where('status', $status))
->when($filters['search'] ?? null, fn ($q, $search) => $q->whereHas('applicant', fn ($x) => $x->where('name', 'ilike', "%{$search}%")->orWhere('email', 'ilike', "%{$search}%")))
->latest('id')->paginate($filters['per_page'] ?? 10);
}
public function tenantMenuGroupOptions(User $reviewer): array
{
$this->authorizeReviewer('tenant', $reviewer, null);
return MenuGroup::query()
->whereNull('tenant_id')
->where('is_system', true)
->where('is_active', true)
->withCount('menus')
->orderBy('name')
->get(['id', 'name', 'description'])
->map(fn (MenuGroup $group) => [
'id' => $group->id,
'name' => $group->name,
'description' => $group->description,
'menus_count' => $group->menus_count,
])->all();
}
public function staffMenuGroupOptions(User $reviewer, ?int $tenantId): array
{
$this->authorizeReviewer('staff', $reviewer, $tenantId);
$assignedGroupIds = UserMenuGroup::query()
->where('user_id', $reviewer->id)
->where('tenant_id', $tenantId)
->pluck('menu_group_id');
return MenuGroup::query()
->where('is_active', true)
->where(function ($query) use ($tenantId, $assignedGroupIds) {
$query->where('tenant_id', $tenantId)
->orWhereIn('id', $assignedGroupIds);
})
->withCount('menus')
->orderBy('name')
->get(['id', 'name', 'description', 'tenant_id'])
->map(fn (MenuGroup $group) => [
'id' => $group->id,
'name' => $group->name,
'description' => $group->description,
'menus_count' => $group->menus_count,
'is_tenant_group' => (int) $group->tenant_id === (int) $tenantId,
])->all();
}
public function review(RoleApplication $application, array $data, User $reviewer, ?int $tenantId): RoleApplication
{
$this->authorizeReviewer($application->application_type, $reviewer, $tenantId, $application->tenant_id);
if ($application->status !== 'pending') {
throw ValidationException::withMessages(['application' => 'Pengajuan ini sudah ditinjau.']);
}
return DB::transaction(function () use ($application, $data, $reviewer) {
if ($data['decision'] === 'approved') {
if ($application->application_type === 'agent' && empty($data['payment_type'])) {
throw ValidationException::withMessages(['payment_type' => 'Kategori pembayaran agen wajib dipilih.']);
}
match ($application->application_type) {
'tenant' => $this->approveTenant($application, (int) $data['menu_group_id']),
'staff' => $this->approveStaff($application, (int) $data['menu_group_id'], $reviewer),
default => $this->approveAgent($application, $data),
};
}
$application->update([
'status' => $data['decision'], 'reviewed_by' => $reviewer->id,
'review_notes' => $data['review_notes'] ?? null, 'reviewed_at' => now(),
]);
return $application->fresh(['applicant:id,name,username,email', 'tenant:id,tenant_code,tenant_name', 'reviewer:id,name']);
});
}
public function linkCustomer(array $data, User $reviewer, int $tenantId): CustomerUserLink
{
abort_unless($reviewer->isMasterAdmin() || in_array($reviewer->tenantRole($tenantId), ['tenant_owner', 'staff'], true), 403);
$customer = Customer::whereKey($data['customer_id'])->where('tenant_id', $tenantId)->firstOrFail();
$link = CustomerUserLink::updateOrCreate(['customer_id' => $customer->id, 'user_id' => $data['user_id']], [
'tenant_id' => $tenantId, 'linked_by' => $reviewer->id,
'relationship' => $data['relationship'] ?? 'owner', 'is_primary' => $data['is_primary'] ?? true,
]);
if (! $customer->user_id && ($data['is_primary'] ?? true)) {
$customer->update(['user_id' => $data['user_id']]);
}
return $link->load(['customer:id,tenant_id,customer_code,name', 'user:id,name,username,email']);
}
private function approveTenant(RoleApplication $application, int $templateGroupId): void
{
$payload = $application->payload;
$group = MenuGroup::query()
->whereNull('tenant_id')
->where('is_system', true)
->where('is_active', true)
->findOrFail($templateGroupId);
$tenant = Tenant::create([
'tenant_code' => $this->uniqueTenantCode($payload['tenant_code'] ?? null), 'tenant_name' => $payload['tenant_name'],
'phone' => $payload['phone'] ?? null, 'email' => $payload['email'] ?? null,
'address' => $payload['address'] ?? null, 'status' => 'active',
]);
$application->tenant_id = $tenant->id;
if (UserTenant::where('user_id', $application->applicant_user_id)->where('role', 'tenant_owner')->exists()) {
throw ValidationException::withMessages([
'application' => 'User sudah menjadi Tenant Owner pada tenant lain.',
]);
}
UserTenant::where('user_id', $application->applicant_user_id)->update(['is_default' => false]);
UserTenant::updateOrCreate(
['user_id' => $application->applicant_user_id, 'tenant_id' => $tenant->id],
['role' => 'tenant_owner', 'is_default' => true]
);
$application->applicant()->update(['access_level' => 'tenant_owner']);
UserMenuGroup::create([
'user_id' => $application->applicant_user_id,
'tenant_id' => $tenant->id,
'menu_group_id' => $group->id,
]);
$application->payload = array_merge($payload, [
'approved_menu_group_id' => $group->id,
'approved_menu_group_name' => $group->name,
]);
}
private function approveStaff(RoleApplication $application, int $menuGroupId, User $reviewer): void
{
$availableGroupIds = collect($this->staffMenuGroupOptions($reviewer, $application->tenant_id))->pluck('id');
if (! $availableGroupIds->contains($menuGroupId)) {
throw ValidationException::withMessages([
'menu_group_id' => 'Group Menu tidak tersedia pada tenant aktif.',
]);
}
$existingTenantRole = UserTenant::query()
->where('user_id', $application->applicant_user_id)
->where('tenant_id', $application->tenant_id)
->value('role');
if ($existingTenantRole === 'tenant_owner') {
throw ValidationException::withMessages([
'application' => 'Tenant Owner tidak dapat diubah menjadi staff pada tenant miliknya.',
]);
}
UserTenant::updateOrCreate(
['user_id' => $application->applicant_user_id, 'tenant_id' => $application->tenant_id],
['role' => 'staff', 'is_default' => ! UserTenant::where('user_id', $application->applicant_user_id)->exists()]
);
if (! $application->applicant->userTenants()->where('role', 'tenant_owner')->exists()) {
$application->applicant()->update(['access_level' => 'staff']);
}
UserMenuGroup::where('user_id', $application->applicant_user_id)
->where('tenant_id', $application->tenant_id)
->delete();
UserMenuGroup::create([
'user_id' => $application->applicant_user_id,
'tenant_id' => $application->tenant_id,
'menu_group_id' => $menuGroupId,
]);
$group = MenuGroup::findOrFail($menuGroupId);
$application->payload = array_merge($application->payload, [
'approved_menu_group_id' => $group->id,
'approved_menu_group_name' => $group->name,
]);
}
private function approveAgent(RoleApplication $application, array $data): void
{
UserTenant::updateOrCreate(['user_id' => $application->applicant_user_id, 'tenant_id' => $application->tenant_id], [
'is_default' => ! UserTenant::where('user_id', $application->applicant_user_id)->exists(),
]);
VoucherAgent::updateOrCreate(['tenant_id' => $application->tenant_id, 'user_id' => $application->applicant_user_id], [
'uuid' => Str::uuid(), 'agent_code' => 'AGT-'.$application->tenant_id.'-'.Str::upper(Str::random(6)),
'payment_type' => $data['payment_type'], 'price_mode' => $data['price_mode'] ?? 'discount',
'discount_percent' => $data['discount_percent'] ?? 0, 'commission_percent' => $data['commission_percent'] ?? 0,
'credit_limit' => $data['payment_type'] === 'postpaid' ? ($data['credit_limit'] ?? 0) : 0,
'status' => 'active', 'notes' => $data['review_notes'] ?? null,
]);
$this->assignDefaultGroup($application->applicant, Tenant::findOrFail($application->tenant_id), 'agent');
}
private function assignDefaultGroup(User $user, Tenant $tenant, string $role): void
{
$name = $role === 'tenant_owner' ? 'Tenant Owner Default' : 'Agen Voucher Default';
$group = MenuGroup::firstOrCreate(['tenant_id' => $tenant->id, 'name' => $name], [
'description' => 'Dibuat otomatis dari approval pengajuan.', 'is_system' => true, 'is_active' => true,
]);
$slugs = $role === 'tenant_owner'
? MenuList::where('is_active', true)->whereNotIn('slug', ['tenants', 'role-applications-tenants'])->pluck('id')
: MenuList::whereIn('slug', ['voucher-hotspot', 'voucher-hotspot-sales', 'wallet'])->pluck('id');
$group->menus()->syncWithoutDetaching($slugs->mapWithKeys(fn ($id) => [$id => [
'can_view' => true, 'can_create' => true, 'can_update' => $role === 'tenant_owner',
'can_delete' => $role === 'tenant_owner', 'can_approve' => $role === 'tenant_owner', 'can_export' => true,
]])->all());
UserMenuGroup::firstOrCreate(['user_id' => $user->id, 'tenant_id' => $tenant->id, 'menu_group_id' => $group->id]);
}
private function authorizeReviewer(string $type, User $reviewer, ?int $tenantId, ?int $applicationTenantId = null): void
{
if ($type === 'tenant') {
abort_unless($reviewer->isMasterAdmin() || $reviewer->is_platform_staff, 403);
}
if ($type === 'agent' && ! $reviewer->isMasterAdmin()) {
abort_unless($tenantId && (! $applicationTenantId || $tenantId === $applicationTenantId)
&& in_array($reviewer->tenantRole($tenantId), ['tenant_owner', 'staff'], true), 403);
}
if ($type === 'staff') {
abort_unless($tenantId
&& (! $applicationTenantId || $tenantId === $applicationTenantId)
&& $reviewer->isTenantOwner($tenantId), 403);
}
}
private function uniqueTenantCode(?string $requested): string
{
$base = Str::upper(Str::slug($requested ?: 'TNT-'.Str::random(6), ''));
$code = $base;
$counter = 1;
while (Tenant::where('tenant_code', $code)->exists()) {
$code = $base.'-'.$counter++;
}
return $code;
}
}
+8 -4
View File
@@ -19,6 +19,8 @@ class AuthService
'whatsapp_number' => $data['whatsapp_number'],
'password' => Hash::make($data['password']),
'is_master' => false,
'is_platform_staff' => false,
'access_level' => 'customer',
'status' => 'inactive',
'verification_channel' => $data['verification_channel'],
'verification_target' => $data['verification_target'],
@@ -32,7 +34,8 @@ class AuthService
'verification' => [
'channel' => $user->verification_channel,
'target' => $user->verification_target,
'code' => $code,
'code' => config('registration.show_verification_code') ? $code : null,
'code_visible' => (bool) config('registration.show_verification_code'),
'expires_at' => $user->verification_code_expires_at,
],
];
@@ -45,7 +48,7 @@ class AuthService
->where('verification_target', $data['verification_target'])
->first();
if (!$user) {
if (! $user) {
throw ValidationException::withMessages([
'verification_target' => 'Target verifikasi tidak ditemukan.',
]);
@@ -83,7 +86,7 @@ class AuthService
->where('verification_target', $data['verification_target'])
->first();
if (!$user) {
if (! $user) {
throw ValidationException::withMessages([
'verification_target' => 'Target verifikasi tidak ditemukan.',
]);
@@ -105,7 +108,8 @@ class AuthService
return [
'channel' => $user->verification_channel,
'target' => $user->verification_target,
'code' => $code,
'code' => config('registration.show_verification_code') ? $code : null,
'code_visible' => (bool) config('registration.show_verification_code'),
'expires_at' => $user->verification_code_expires_at,
];
}
+12
View File
@@ -100,6 +100,18 @@ class CustomerService
'registration',
['action_url' => "/customers/orders/{$customer->id}/detail"],
);
User::query()
->whereHas('userTenants', fn ($query) => $query
->where('tenant_id', $customer->tenant_id)
->where('role', 'tenant_owner'))
->each(fn (User $owner) => $this->notifications->emit(
'customer.registration',
$owner,
$customer,
['customer' => $customer->toArray(), 'tenant' => $customer->tenant?->toArray() ?? []],
'registration',
['action_url' => "/customers/orders/{$customer->id}/detail", 'icon' => 'cil-user-follow'],
));
return $customer->load(self::DETAIL_RELATIONS);
}
@@ -4,6 +4,7 @@ namespace App\Services\Notification\Adapters;
use App\Models\NotificationDelivery;
use App\Services\Notification\Contracts\NotificationChannelAdapter;
use App\Services\Notification\WhapiIdService;
use Illuminate\Support\Facades\Http;
use Illuminate\Validation\ValidationException;
@@ -12,6 +13,11 @@ class WhatsAppUnofficialAdapter implements NotificationChannelAdapter
public function send(NotificationDelivery $delivery): array
{
$config = $delivery->channelConfig;
if ($config?->provider === 'whapi_id') {
$response = app(WhapiIdService::class)->send($config, $delivery->destination, $delivery->rendered_body);
return ['provider_message_id' => data_get($response, 'results.id_message'), 'response' => $response];
}
$credentials = $config?->credentials ?? [];
$settings = $config?->settings ?? [];
$url = $settings['send_url'] ?? null;
@@ -40,12 +40,17 @@ class NotificationManager
}
return DB::transaction(function () use ($event, $recipient, $subject, $payload, $occurrenceKey, $context, $tenantId) {
$recipientOccurrenceKey = Str::limit(
$occurrenceKey.'|'.$recipient->getMorphClass().'|'.$recipient->getKey(),
255,
''
);
$occurrence = NotificationEventOccurrence::firstOrCreate([
'tenant_id' => $tenantId,
'notification_event_id' => $event->id,
'subjectable_type' => $subject->getMorphClass(),
'subjectable_id' => $subject->getKey(),
'occurrence_key' => $occurrenceKey,
'occurrence_key' => $recipientOccurrenceKey,
]);
if (! $occurrence->wasRecentlyCreated) {
return null;
@@ -57,6 +62,20 @@ class NotificationManager
->where('enabled', true)
->orderBy('priority')
->get();
$systemConfigured = NotificationPreference::where('tenant_id', $tenantId)
->where('notification_event_id', $event->id)
->where('channel', 'system')
->exists();
if (! $systemConfigured && in_array('system', $event->available_channels ?? [], true)) {
$preferences->prepend(new NotificationPreference([
'tenant_id' => $tenantId,
'notification_event_id' => $event->id,
'channel' => 'system',
'enabled' => true,
'priority' => 1,
'send_delay_minutes' => 0,
]));
}
if ($preferences->isEmpty()) {
$occurrence->update(['processed_at' => now()]);
@@ -4,6 +4,7 @@ namespace App\Services\Notification;
use App\Jobs\ProcessBroadcast;
use App\Jobs\ProcessNotificationDelivery;
use App\Jobs\ProcessSystemNotificationCampaign;
use App\Models\Customer;
use App\Models\CustomerContact;
use App\Models\NotificationBroadcast;
@@ -14,6 +15,7 @@ use App\Models\NotificationEvent;
use App\Models\NotificationMessage;
use App\Models\NotificationPreference;
use App\Models\NotificationTemplate;
use App\Models\SystemNotificationCampaign;
use App\Models\TopologyLink;
use App\Models\User;
use App\Models\UserNotification;
@@ -32,29 +34,126 @@ class NotificationService
return NotificationChannel::with('tenant:id,tenant_code,tenant_name')
->where('channel', $channel)
->when(! $actor->isMasterAdmin(), fn ($q) => $q->where('tenant_id', $tenantId))
->when($actor->isMasterAdmin() && $tenantId, fn ($q) => $q->where('tenant_id', $tenantId))
->when($actor->isMasterAdmin() && $tenantId, fn ($q) => $q->where(
fn ($scope) => $scope->where('tenant_id', $tenantId)->orWhereNull('tenant_id')
))
->orderByDesc('is_default')->orderBy('name')->get()
->each(fn ($item) => $item->setAttribute('credentials_configured', ! empty($item->getRawOriginal('credentials'))));
}
public function systemCampaigns(array $filters, User $actor, ?int $tenantId)
{
return SystemNotificationCampaign::with(['tenant:id,tenant_code,tenant_name', 'creator:id,name'])
->when(! $actor->isMasterAdmin(), fn ($q) => $q->where('tenant_id', $tenantId))
->when($actor->isMasterAdmin() && ! empty($filters['tenant_id']), fn ($q) => $q->where('tenant_id', $filters['tenant_id']))
->when(! empty($filters['status']), fn ($q) => $q->where('status', $filters['status']))
->latest('id')->paginate($filters['per_page'] ?? 10);
}
public function createSystemCampaign(array $data, User $actor, ?int $tenantId): SystemNotificationCampaign
{
$effectiveTenantId = $actor->isMasterAdmin() ? ($data['tenant_id'] ?? null) : $tenantId;
if (! $actor->isMasterAdmin() && ! $effectiveTenantId) {
throw ValidationException::withMessages(['tenant_id' => 'Tenant aktif wajib tersedia.']);
}
if (! $actor->isMasterAdmin() && ($data['target_scope'] ?? null) === 'all_users') {
throw ValidationException::withMessages(['target_scope' => 'Hanya Master Admin yang dapat mengirim ke semua user.']);
}
if (($data['target_scope'] ?? null) === 'selected_users' && empty($data['user_ids'])) {
throw ValidationException::withMessages(['user_ids' => 'Pilih minimal satu user penerima.']);
}
if ($effectiveTenantId && ! empty($data['user_ids'])) {
$validCount = User::whereIn('id', $data['user_ids'])
->whereHas('userTenants', fn ($q) => $q->where('tenant_id', $effectiveTenantId))->count();
if ($validCount !== count(array_unique($data['user_ids']))) {
throw ValidationException::withMessages(['user_ids' => 'Terdapat user di luar tenant aktif.']);
}
}
$campaign = SystemNotificationCampaign::create([
'uuid' => Str::uuid(),
'tenant_id' => $effectiveTenantId,
'created_by' => $actor->id,
'title' => $data['title'],
'body' => $data['body'],
'action_url' => $data['action_url'] ?? null,
'icon' => $data['icon'] ?? 'cil-bell',
'audience' => [
'target_scope' => $data['target_scope'],
'statuses' => $data['statuses'] ?? [],
'access_levels' => $data['access_levels'] ?? [],
'user_ids' => $data['user_ids'] ?? [],
],
]);
ProcessSystemNotificationCampaign::dispatch($campaign->id)->onQueue('system-notifications');
return $campaign;
}
public function systemAudienceOptions(User $actor, ?int $tenantId, ?int $requestedTenantId, ?string $search)
{
$effectiveTenantId = $actor->isMasterAdmin() ? $requestedTenantId : $tenantId;
return User::query()
->when($effectiveTenantId, fn ($q) => $q->whereHas('userTenants', fn ($x) => $x->where('tenant_id', $effectiveTenantId)))
->when($search, fn ($q, $term) => $q->where(fn ($x) => $x
->where('name', 'ILIKE', "%{$term}%")
->orWhere('username', 'ILIKE', "%{$term}%")
->orWhere('email', 'ILIKE', "%{$term}%")))
->with(['userTenants' => fn ($q) => $q->when($effectiveTenantId, fn ($x) => $x->where('tenant_id', $effectiveTenantId))])
->orderBy('name')->limit(50)->get(['id', 'name', 'username', 'email', 'access_level', 'status'])
->map(fn (User $user) => [
...$user->only(['id', 'name', 'username', 'email', 'status']),
'access_level' => $user->access_level->value,
'tenant_role' => $user->userTenants->first()?->role,
]);
}
public function saveChannel(array $data, User $actor, ?int $tenantId, ?NotificationChannel $channel = null): NotificationChannel
{
$effectiveTenant = $actor->isMasterAdmin() ? ($data['tenant_id'] ?? $channel?->tenant_id ?? $tenantId) : $tenantId;
if (! $effectiveTenant) {
$effectiveTenant = $actor->isMasterAdmin()
? (array_key_exists('tenant_id', $data) ? $data['tenant_id'] : ($channel ? $channel->tenant_id : $tenantId))
: $tenantId;
$isGlobalWhapi = $actor->isMasterAdmin()
&& ($data['channel'] ?? $channel?->channel) === 'whatsapp_unofficial'
&& ! $effectiveTenant;
if (! $effectiveTenant && ! $isGlobalWhapi) {
throw ValidationException::withMessages(['tenant_id' => 'Tenant wajib dipilih.']);
}
if ($channel && (int) $channel->tenant_id !== (int) $effectiveTenant) {
if ($channel && $channel->tenant_id !== null && (int) $channel->tenant_id !== (int) $effectiveTenant) {
throw ValidationException::withMessages(['tenant_id' => 'Konfigurasi bukan milik tenant tersebut.']);
}
if ($channel && $channel->tenant_id === null && ! $isGlobalWhapi) {
throw ValidationException::withMessages(['tenant_id' => 'Konfigurasi global hanya dapat digunakan untuk WHAPI Master Admin.']);
}
$credentials = array_filter($data['credentials'] ?? [], fn ($value) => $value !== '' && $value !== null);
if ($channel && $credentials) {
$credentials = [...($channel->credentials ?? []), ...$credentials];
}
if (($data['is_default'] ?? false) === true) {
NotificationChannel::where('tenant_id', $effectiveTenant)->where('channel', $data['channel'])
NotificationChannel::where('channel', $data['channel'])
->when($effectiveTenant, fn ($q) => $q->where('tenant_id', $effectiveTenant), fn ($q) => $q->whereNull('tenant_id'))
->when($channel, fn ($q) => $q->whereKeyNot($channel->id))
->update(['is_default' => false]);
}
if ($data['channel'] === 'whatsapp_unofficial') {
$data['provider'] = 'whapi_id';
$apiKey = $credentials['api_key'] ?? data_get($channel?->credentials, 'api_key');
$baseUrl = data_get($data, 'settings.base_url', data_get($channel?->settings, 'base_url'));
if (! $apiKey || ! $baseUrl) {
throw ValidationException::withMessages([
'credentials' => 'API Key perangkat dan Base URL WHAPI wajib diisi.',
]);
}
if ($isGlobalWhapi && NotificationChannel::whereNull('tenant_id')
->where('channel', 'whatsapp_unofficial')
->when($channel, fn ($q) => $q->whereKeyNot($channel->id))
->exists()) {
throw ValidationException::withMessages([
'channel' => 'Konfigurasi WHAPI global sudah tersedia. Silakan perbarui konfigurasi tersebut.',
]);
}
}
$payload = [
...$data,
'tenant_id' => $effectiveTenant,
@@ -72,6 +171,16 @@ class NotificationService
->setAttribute('credentials_configured', ! empty($channel->getRawOriginal('credentials')));
}
public function deleteChannel(NotificationChannel $channel): void
{
if ($channel->templates()->exists() || $channel->deliveries()->exists()) {
$channel->update(['enabled' => false, 'is_default' => false]);
return;
}
$channel->delete();
}
public function templates(string $channel, User $actor, ?int $tenantId)
{
return NotificationTemplate::with(['event:id,code,name,category,variables', 'channelConfig:id,name'])
@@ -170,7 +279,9 @@ class NotificationService
'system' => 'Penyimpanan notifikasi aplikasi siap.',
'telegram' => data_get(Http::timeout(15)->get('https://api.telegram.org/bot'.$credentials['bot_token'].'/getMe')->throw()->json(), 'result.username', 'Bot Telegram terhubung.'),
'whatsapp_official' => data_get(Http::withToken($credentials['access_token'])->timeout(15)->get(rtrim($settings['base_url'] ?? 'https://graph.facebook.com/v22.0', '/').'/'.$credentials['phone_number_id'])->throw()->json(), 'display_phone_number', 'WhatsApp Official terhubung.'),
'whatsapp_unofficial' => data_get(Http::withHeaders(isset($credentials['api_key']) ? [($settings['api_key_header'] ?? 'X-API-Key') => $credentials['api_key']] : [])->timeout(15)->get($settings['health_url'])->throw()->json(), 'status', 'Gateway terhubung.'),
'whatsapp_unofficial' => $channel->provider === 'whapi_id'
? data_get(app(WhapiIdService::class)->state($channel), 'results.state', 'WHAPI terhubung.')
: data_get(Http::withHeaders(isset($credentials['api_key']) ? [($settings['api_key_header'] ?? 'X-API-Key') => $credentials['api_key']] : [])->timeout(15)->get($settings['health_url'])->throw()->json(), 'status', 'Gateway terhubung.'),
'email' => ! empty($settings['host']) && ! empty($settings['from_address']) ? 'Konfigurasi SMTP lengkap.' : throw new \RuntimeException('Host dan alamat pengirim SMTP wajib diisi.'),
};
$channel->update(['last_health_status' => 'healthy', 'last_health_message' => (string) $message, 'last_health_check_at' => now()]);
@@ -0,0 +1,93 @@
<?php
namespace App\Services\Notification;
use App\Models\NotificationChannel;
use Illuminate\Http\Client\ConnectionException;
use Illuminate\Http\Client\PendingRequest;
use Illuminate\Http\Client\RequestException;
use Illuminate\Support\Facades\Http;
use Illuminate\Validation\ValidationException;
class WhapiIdService
{
public function state(NotificationChannel $channel): array
{
return $this->get($channel, '/api/getState');
}
public function start(NotificationChannel $channel): array
{
return $this->get($channel, '/api/serviceStart');
}
public function qr(NotificationChannel $channel): array
{
return $this->get($channel, '/api/getQR');
}
public function send(NotificationChannel $channel, string $phone, string $message): array
{
try {
return $this->client($channel)->asForm()->post('/api/sendMessage', [
...$this->auth($channel), 'phone' => $this->normalizePhone($phone),
'message' => $message, 'forward_type' => 0, 'ephemeral' => 0,
])->throw()->json();
} catch (RequestException|ConnectionException $exception) {
$this->providerError($exception);
}
}
private function client(NotificationChannel $channel): PendingRequest
{
$baseUrl = rtrim((string) data_get($channel->settings, 'base_url'), '/');
if (! $baseUrl || ! preg_match('#^https?://#i', $baseUrl)) {
throw ValidationException::withMessages(['base_url' => 'Base URL WHAPI wajib menggunakan HTTP atau HTTPS.']);
}
$baseUrl = preg_replace('#/api$#i', '', $baseUrl);
return Http::baseUrl($baseUrl)->acceptJson()->timeout(30)->retry(2, 500);
}
private function get(NotificationChannel $channel, string $path): array
{
try {
return $this->client($channel)->get($path, $this->auth($channel))->throw()->json();
} catch (RequestException|ConnectionException $exception) {
$this->providerError($exception);
}
}
private function providerError(RequestException|ConnectionException $exception): never
{
$status = $exception instanceof RequestException ? $exception->response->status() : null;
$providerMessage = $exception instanceof RequestException
? data_get($exception->response->json(), 'results.message')
?? data_get($exception->response->json(), 'message')
: null;
throw ValidationException::withMessages([
'whapi' => $providerMessage
?: ($status ? "WHAPI merespons HTTP {$status}. Periksa Base URL dan endpoint gateway." : 'Server WHAPI tidak dapat dihubungi.'),
]);
}
private function auth(NotificationChannel $channel): array
{
$apiKey = data_get($channel->credentials, 'api_key');
if (! $apiKey) {
throw ValidationException::withMessages(['api_key' => 'API Key perangkat WHAPI belum diatur.']);
}
return ['apiKey' => $apiKey];
}
private function normalizePhone(string $phone): string
{
$phone = preg_replace('/\D+/', '', $phone);
if (str_starts_with($phone, '0')) {
$phone = '62'.substr($phone, 1);
}
return $phone;
}
}
+23
View File
@@ -65,6 +65,7 @@ class UserService
'profile_photo_file_id',
'password',
'is_master',
'is_platform_staff',
'access_level',
'status',
]);
@@ -109,6 +110,7 @@ class UserService
'whatsapp_number',
'profile_photo_file_id',
'is_master',
'is_platform_staff',
'access_level',
'status',
]);
@@ -176,9 +178,12 @@ class UserService
protected function syncTenants(User $user, array $rows): void
{
$existingRoles = $user->userTenants()->pluck('role', 'tenant_id');
$normalized = collect($rows)
->map(fn ($row) => [
'tenant_id' => $row['tenant_id'] ?? null,
'role' => $row['role'] ?? $existingRoles->get($row['tenant_id'] ?? null)
?? ($user->access_level === UserAccessLevel::STAFF ? 'staff' : 'customer'),
'is_default' => (bool) ($row['is_default'] ?? false),
])
->filter(fn ($row) => ! empty($row['tenant_id']))
@@ -186,6 +191,12 @@ class UserService
->values()
->all();
if (collect($normalized)->where('role', 'tenant_owner')->count() > 1) {
throw ValidationException::withMessages([
'user_tenants' => 'Satu user hanya boleh menjadi Tenant Owner pada satu tenant.',
]);
}
$defaultSet = false;
foreach ($normalized as $index => $row) {
if ($row['is_default'] && ! $defaultSet) {
@@ -243,6 +254,13 @@ class UserService
return;
}
if ($payload['is_platform_staff'] ?? false) {
throw ValidationException::withMessages([
'is_platform_staff' => 'Hanya Master Admin yang dapat memberikan akses staff platform.',
]);
}
unset($payload['is_platform_staff']);
if ($target?->isMasterAdmin()) {
throw ValidationException::withMessages([
'user' => 'Master Admin hanya dapat diubah oleh Master Admin.',
@@ -275,6 +293,11 @@ class UserService
$key => 'Assignment hanya boleh dibuat untuk tenant yang sedang aktif.',
]);
}
if ($key === 'user_tenants' && ($row['role'] ?? null) === 'tenant_owner') {
throw ValidationException::withMessages([
$key => 'Tenant Owner tidak dapat memberikan role Tenant Owner.',
]);
}
}
}
}
+241
View File
@@ -0,0 +1,241 @@
<?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);
}
}
}
+75 -9
View File
@@ -3,7 +3,9 @@
namespace App\Services\Wallet;
use App\Models\Tenant;
use App\Models\TenantPaymentSetting;
use App\Models\User;
use App\Models\VoucherAgent;
use App\Models\Wallet;
use App\Models\WalletLedgerEntry;
use App\Models\WalletTransaction;
@@ -15,14 +17,60 @@ use Illuminate\Validation\ValidationException;
class WalletService
{
public function chargeUser(User $user, int $tenantId, float $amount, string $idempotencyKey, string $description): WalletTransaction
{
return DB::transaction(function () use ($user, $tenantId, $amount, $idempotencyKey, $description) {
$existing = WalletTransaction::where('idempotency_key', $idempotencyKey)->first();
if ($existing) {
return $existing;
}
$wallet = Wallet::lockForUpdate()->find($this->walletFor($user, $tenantId, 'deposit')->id);
if ((float) $wallet->available_balance < $amount) {
throw ValidationException::withMessages(['wallet' => 'Saldo deposit agen pada tenant ini tidak mencukupi.']);
}
$transaction = WalletTransaction::create([
'uuid' => Str::uuid(), 'transaction_number' => 'WLT-'.now()->format('YmdHis').'-'.Str::upper(Str::random(6)),
'type' => 'voucher_purchase', 'status' => 'processing', 'initiated_by' => $user->id,
'amount' => $amount, 'fee' => 0, 'idempotency_key' => $idempotencyKey, 'description' => $description,
]);
$this->post($wallet, $transaction, 'debit', $amount);
$transaction->update(['status' => 'completed', 'completed_at' => now()]);
return $transaction;
});
}
public function wallets(User $user, ?int $tenantId)
{
$wallets = collect([$this->walletFor($user, 'user')]);
if ($tenantId && ($user->isMasterAdmin() || $user->isTenantOwner())) {
$wallets->push($this->walletFor(Tenant::findOrFail($tenantId), 'tenant'));
if (! $tenantId) {
return collect();
}
return $wallets->values();
$balances = collect([$this->balanceData($this->walletFor($user, $tenantId, 'deposit'), 'deposit', 'Saldo Deposit')]);
$usesGlobalGateway = TenantPaymentSetting::where('tenant_id', $tenantId)
->where('payment_mode', 'platform_gateway')->whereNotNull('platform_gateway_account_id')->exists();
if ($usesGlobalGateway && ($user->isMasterAdmin() || $user->isTenantOwner($tenantId))) {
$balances->push($this->balanceData($this->walletFor(Tenant::findOrFail($tenantId), $tenantId, 'tenant_settlement'), 'tenant_settlement', 'Saldo Settlement Tenant'));
}
$agent = VoucherAgent::where('tenant_id', $tenantId)->where('user_id', $user->id)
->where('payment_type', 'postpaid')->where('status', 'active')->first();
if ($agent) {
$balances->push([
'id' => "credit-{$agent->id}", 'uuid' => $agent->uuid, 'tenant_id' => $tenantId,
'wallet_type' => 'voucher_credit', 'balance_kind' => 'credit', 'label' => 'Saldo Kredit Voucher',
'credit_limit' => $agent->credit_limit, 'used_credit' => $agent->outstanding_balance,
'remaining_credit' => number_format(max(0, (float) $agent->credit_limit - (float) $agent->outstanding_balance), 2, '.', ''),
'available_balance' => number_format(max(0, (float) $agent->credit_limit - (float) $agent->outstanding_balance), 2, '.', ''),
'pending_balance' => '0.00', 'locked_balance' => '0.00', 'currency' => 'IDR',
'status' => $agent->status, 'ledger_available' => false, 'can_withdraw' => false, 'can_transfer' => false,
'usage_note' => 'Khusus untuk pembelian voucher hotspot pada tenant ini.',
]);
}
return $balances->values();
}
public function ledger(Wallet $wallet, User $actor, ?int $tenantId, array $filters)
@@ -37,6 +85,9 @@ class WalletService
{
$this->authorizeWallet($source, $actor, $tenantId);
$this->authorizeWallet($destination, $actor, $tenantId);
if ((int) $source->tenant_id !== (int) $destination->tenant_id || (int) $source->tenant_id !== (int) $tenantId) {
throw ValidationException::withMessages(['destination_wallet_id' => 'Saldo tidak dapat dipindahkan antar tenant.']);
}
if ($source->is($destination)) {
throw ValidationException::withMessages(['destination_wallet_id' => 'Dompet tujuan harus berbeda.']);
}
@@ -63,6 +114,9 @@ class WalletService
public function requestWithdrawal(Wallet $wallet, array $data, User $actor, ?int $tenantId): WalletWithdrawal
{
$this->authorizeWallet($wallet, $actor, $tenantId);
if ($wallet->wallet_type !== 'tenant_settlement') {
throw ValidationException::withMessages(['wallet' => 'Saldo deposit user hanya dapat digunakan untuk transaksi layanan dan tidak dapat ditarik tunai.']);
}
return DB::transaction(function () use ($wallet, $data, $actor) {
$locked = Wallet::lockForUpdate()->findOrFail($wallet->id);
@@ -82,14 +136,25 @@ class WalletService
});
}
private function walletFor(Model $owner, string $type): Wallet
private function walletFor(Model $owner, int $tenantId, string $type): Wallet
{
return Wallet::firstOrCreate(
['owner_type' => $owner->getMorphClass(), 'owner_id' => $owner->getKey(), 'wallet_type' => $type, 'currency' => 'IDR'],
['owner_type' => $owner->getMorphClass(), 'owner_id' => $owner->getKey(), 'tenant_id' => $tenantId, 'wallet_type' => $type, 'currency' => 'IDR'],
['uuid' => Str::uuid(), 'status' => 'active']
);
}
private function balanceData(Wallet $wallet, string $kind, string $label): array
{
return [
...$wallet->toArray(), 'balance_kind' => $kind, 'label' => $label, 'ledger_available' => true,
'can_withdraw' => $kind === 'tenant_settlement', 'can_transfer' => false,
'usage_note' => $kind === 'deposit'
? 'Dapat digunakan untuk transaksi layanan pada tenant ini.'
: 'Dana tenant dari transaksi melalui payment gateway global.',
];
}
private function newTransaction(string $type, float $amount, User $actor, string $description, float $fee = 0): WalletTransaction
{
return WalletTransaction::create([
@@ -116,8 +181,9 @@ class WalletService
if ($actor->isMasterAdmin()) {
return;
}
$allowed = ($wallet->owner_type === $actor->getMorphClass() && (int) $wallet->owner_id === (int) $actor->id)
|| ($actor->isTenantOwner() && $wallet->owner_type === (new Tenant)->getMorphClass() && (int) $wallet->owner_id === (int) $tenantId);
abort_unless($allowed, 403, 'Dompet tidak dapat diakses.');
$sameTenant = $tenantId && (int) $wallet->tenant_id === (int) $tenantId;
$allowed = $sameTenant && (($wallet->owner_type === $actor->getMorphClass() && (int) $wallet->owner_id === (int) $actor->id)
|| ($actor->isTenantOwner($tenantId) && $wallet->owner_type === (new Tenant)->getMorphClass() && (int) $wallet->owner_id === (int) $tenantId));
abort_unless($allowed, 403, 'Saldo tidak dapat diakses.');
}
}