Giant Update
This commit is contained in:
@@ -0,0 +1,24 @@
|
||||
<?php
|
||||
|
||||
namespace App\Console\Commands;
|
||||
|
||||
use App\Services\Billing\InvoiceService;
|
||||
use Illuminate\Console\Command;
|
||||
|
||||
class ProcessBillingSchedules extends Command
|
||||
{
|
||||
protected $signature = 'billing:process-schedules';
|
||||
|
||||
protected $description = 'Membuat tagihan customer dan memperbarui status sesuai jadwal profile tagihan';
|
||||
|
||||
public function handle(InvoiceService $service): int
|
||||
{
|
||||
$result = $service->processSchedules();
|
||||
|
||||
$this->info(
|
||||
"Scheduler tagihan selesai: {$result['created']} dibuat, {$result['updated']} status diperbarui.",
|
||||
);
|
||||
|
||||
return self::SUCCESS;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
<?php
|
||||
|
||||
namespace App\Console\Commands;
|
||||
|
||||
use App\Models\NotificationChannel;
|
||||
use App\Models\NotificationWebhookLog;
|
||||
use App\Models\UserNotification;
|
||||
use Illuminate\Console\Command;
|
||||
|
||||
class PruneNotifications extends Command
|
||||
{
|
||||
protected $signature = 'notifications:prune';
|
||||
|
||||
protected $description = 'Menghapus notifikasi aplikasi dan log webhook yang melewati masa retensi';
|
||||
|
||||
public function handle(): int
|
||||
{
|
||||
$deleted = 0;
|
||||
NotificationChannel::where('channel', 'system')->where('enabled', true)->chunkById(100, function ($channels) use (&$deleted) {
|
||||
foreach ($channels as $channel) {
|
||||
$days = max(7, (int) data_get($channel->settings, 'retention_days', 90));
|
||||
$deleted += UserNotification::where('tenant_id', $channel->tenant_id)
|
||||
->where('created_at', '<', now()->subDays($days))->delete();
|
||||
}
|
||||
});
|
||||
NotificationWebhookLog::where('created_at', '<', now()->subDays(90))->delete();
|
||||
$this->info("{$deleted} notifikasi lama dihapus.");
|
||||
|
||||
return self::SUCCESS;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Billing;
|
||||
|
||||
use App\Http\Controllers\ApiController;
|
||||
use App\Http\Requests\Billing\IndexBillingRequest;
|
||||
use App\Http\Requests\Billing\StoreBillingProfileRequest;
|
||||
use App\Http\Requests\Billing\UpdateBillingProfileRequest;
|
||||
use App\Http\Resources\Billing\BillingProfileResource;
|
||||
use App\Services\Billing\BillingProfileService;
|
||||
use Illuminate\Http\Request;
|
||||
|
||||
class BillingProfileController extends ApiController
|
||||
{
|
||||
public function __construct(protected BillingProfileService $service) {}
|
||||
|
||||
public function index(IndexBillingRequest $request)
|
||||
{
|
||||
return $this->successPaginated($this->service->list($request->validated(), $request->user(), $this->tenantId($request)), BillingProfileResource::class, 'Daftar profile tagihan berhasil diambil');
|
||||
}
|
||||
|
||||
public function store(StoreBillingProfileRequest $request)
|
||||
{
|
||||
return $this->created(new BillingProfileResource($this->service->create($request->validated(), $request->user(), $this->tenantId($request))), 'Profile tagihan berhasil dibuat');
|
||||
}
|
||||
|
||||
public function show(Request $request, int $id)
|
||||
{
|
||||
$profile = $this->service->find($id);
|
||||
$this->authorizeTenant($request, $profile->tenant_id);
|
||||
|
||||
return $this->success(new BillingProfileResource($profile), 'Detail profile tagihan berhasil diambil');
|
||||
}
|
||||
|
||||
public function update(UpdateBillingProfileRequest $request, int $id)
|
||||
{
|
||||
$profile = $this->service->find($id);
|
||||
$this->authorizeTenant($request, $profile->tenant_id);
|
||||
|
||||
return $this->updated(new BillingProfileResource($this->service->update($profile, $request->validated(), $request->user())), 'Profile tagihan berhasil diperbarui');
|
||||
}
|
||||
|
||||
public function destroy(Request $request, int $id)
|
||||
{
|
||||
$profile = $this->service->find($id);
|
||||
$this->authorizeTenant($request, $profile->tenant_id);
|
||||
$this->service->delete($profile);
|
||||
|
||||
return $this->deleted('Profile tagihan berhasil dihapus');
|
||||
}
|
||||
|
||||
private function tenantId(Request $request): ?int
|
||||
{
|
||||
return $request->attributes->get('tenant_id');
|
||||
}
|
||||
|
||||
private function authorizeTenant(Request $request, int $tenantId): void
|
||||
{
|
||||
abort_unless($request->user()->isMasterAdmin() || $tenantId === $this->tenantId($request), 403);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Billing;
|
||||
|
||||
use App\Http\Controllers\ApiController;
|
||||
use App\Http\Requests\Billing\IndexBillingRequest;
|
||||
use App\Http\Requests\Billing\PayInvoiceRequest;
|
||||
use App\Http\Requests\Billing\StoreInvoiceRequest;
|
||||
use App\Http\Requests\Billing\UpdateInvoiceRequest;
|
||||
use App\Http\Resources\Billing\InvoiceResource;
|
||||
use App\Services\Billing\InvoiceService;
|
||||
use Illuminate\Http\Request;
|
||||
|
||||
class InvoiceController extends ApiController
|
||||
{
|
||||
public function __construct(protected InvoiceService $service) {}
|
||||
|
||||
public function index(IndexBillingRequest $request)
|
||||
{
|
||||
return $this->successPaginated($this->service->list($this->scope($request), $request->validated(), $request->user(), $this->tenantId($request)), InvoiceResource::class, 'Daftar tagihan berhasil diambil');
|
||||
}
|
||||
|
||||
public function options(Request $request)
|
||||
{
|
||||
return $this->success($this->service->options($request->user(), $this->tenantId($request)), 'Pilihan tagihan berhasil diambil');
|
||||
}
|
||||
|
||||
public function store(StoreInvoiceRequest $request)
|
||||
{
|
||||
return $this->created(new InvoiceResource($this->service->create($request->validated(), $request->user(), $this->tenantId($request))), 'Tagihan berhasil dibuat');
|
||||
}
|
||||
|
||||
public function show(Request $request, int $id)
|
||||
{
|
||||
$invoice = $this->service->find($id);
|
||||
$this->authorizeTenant($request, $invoice->tenant_id);
|
||||
|
||||
return $this->success(new InvoiceResource($invoice), 'Detail tagihan berhasil diambil');
|
||||
}
|
||||
|
||||
public function update(UpdateInvoiceRequest $request, int $id)
|
||||
{
|
||||
$invoice = $this->service->find($id);
|
||||
$this->authorizeTenant($request, $invoice->tenant_id);
|
||||
|
||||
return $this->updated(new InvoiceResource($this->service->update($invoice, $request->validated(), $request->user())), 'Tagihan berhasil diperbarui');
|
||||
}
|
||||
|
||||
public function pay(PayInvoiceRequest $request, int $id)
|
||||
{
|
||||
$invoice = $this->service->find($id);
|
||||
$this->authorizeTenant($request, $invoice->tenant_id);
|
||||
|
||||
return $this->updated(new InvoiceResource($this->service->pay($invoice, $request->validated(), $request->user())), 'Pembayaran manual berhasil dicatat');
|
||||
}
|
||||
|
||||
public function cancel(Request $request, int $id)
|
||||
{
|
||||
$invoice = $this->service->find($id);
|
||||
$this->authorizeTenant($request, $invoice->tenant_id);
|
||||
|
||||
return $this->updated(new InvoiceResource($this->service->cancel($invoice, $request->user())), 'Tagihan berhasil dibatalkan');
|
||||
}
|
||||
|
||||
private function scope(Request $request): string
|
||||
{
|
||||
return $request->route()->defaults['invoice_scope'] ?? 'running';
|
||||
}
|
||||
|
||||
private function tenantId(Request $request): ?int
|
||||
{
|
||||
return $request->attributes->get('tenant_id');
|
||||
}
|
||||
|
||||
private function authorizeTenant(Request $request, int $tenantId): void
|
||||
{
|
||||
abort_unless($request->user()->isMasterAdmin() || $tenantId === $this->tenantId($request), 403);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,118 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Customer;
|
||||
|
||||
use App\Http\Controllers\ApiController;
|
||||
use App\Http\Requests\Customer\ActivateCustomerRequest;
|
||||
use App\Http\Requests\Customer\IndexCustomerRequest;
|
||||
use App\Http\Requests\Customer\StoreCustomerRequest;
|
||||
use App\Http\Requests\Customer\UpdateCustomerRequest;
|
||||
use App\Http\Resources\Customer\CustomerResource;
|
||||
use App\Services\Customer\CustomerService;
|
||||
use Illuminate\Http\Request;
|
||||
|
||||
class CustomerController extends ApiController
|
||||
{
|
||||
public function __construct(protected CustomerService $service) {}
|
||||
|
||||
public function index(IndexCustomerRequest $request)
|
||||
{
|
||||
return $this->successPaginated(
|
||||
$this->service->list($this->scope($request), $request->validated(), $request->user(), $this->tenantId($request)),
|
||||
CustomerResource::class,
|
||||
'Daftar customer berhasil diambil'
|
||||
);
|
||||
}
|
||||
|
||||
public function options(Request $request)
|
||||
{
|
||||
return $this->success(
|
||||
$this->service->options($request->user(), $this->tenantId($request)),
|
||||
'Pilihan relasi customer berhasil diambil'
|
||||
);
|
||||
}
|
||||
|
||||
public function store(StoreCustomerRequest $request)
|
||||
{
|
||||
return $this->created(
|
||||
new CustomerResource($this->service->create($request->validated(), $request->user(), $this->tenantId($request))),
|
||||
'Customer berhasil didaftarkan'
|
||||
);
|
||||
}
|
||||
|
||||
public function show(Request $request, int $id)
|
||||
{
|
||||
$customer = $this->service->find($id, $this->scope($request) === 'trash');
|
||||
$this->authorizeTenant($request, $customer->tenant_id);
|
||||
|
||||
return $this->success(new CustomerResource($customer), 'Detail customer berhasil diambil');
|
||||
}
|
||||
|
||||
public function update(UpdateCustomerRequest $request, int $id)
|
||||
{
|
||||
$customer = $this->service->find($id);
|
||||
$this->authorizeTenant($request, $customer->tenant_id);
|
||||
|
||||
return $this->updated(
|
||||
new CustomerResource($this->service->update($customer, $request->validated(), $request->user())),
|
||||
'Data customer berhasil diperbarui'
|
||||
);
|
||||
}
|
||||
|
||||
public function activate(ActivateCustomerRequest $request, int $id)
|
||||
{
|
||||
$customer = $this->service->find($id);
|
||||
$this->authorizeTenant($request, $customer->tenant_id);
|
||||
|
||||
return $this->updated(new CustomerResource($this->service->activate($customer, $request->validated())), 'Customer berhasil diaktifkan');
|
||||
}
|
||||
|
||||
public function deactivate(Request $request, int $id)
|
||||
{
|
||||
$customer = $this->service->find($id);
|
||||
$this->authorizeTenant($request, $customer->tenant_id);
|
||||
|
||||
return $this->updated(new CustomerResource($this->service->deactivate($customer)), 'Customer berhasil dinonaktifkan');
|
||||
}
|
||||
|
||||
public function destroy(Request $request, int $id)
|
||||
{
|
||||
$customer = $this->service->find($id);
|
||||
$this->authorizeTenant($request, $customer->tenant_id);
|
||||
$this->service->trash($customer);
|
||||
|
||||
return $this->deleted('Customer dipindahkan ke Sampah');
|
||||
}
|
||||
|
||||
public function restore(Request $request, int $id)
|
||||
{
|
||||
$customer = $this->service->find($id, true);
|
||||
$this->authorizeTenant($request, $customer->tenant_id);
|
||||
|
||||
return $this->updated(new CustomerResource($this->service->restore($customer)), 'Customer berhasil dipulihkan');
|
||||
}
|
||||
|
||||
public function forceDelete(Request $request, int $id)
|
||||
{
|
||||
$customer = $this->service->find($id, true);
|
||||
$this->authorizeTenant($request, $customer->tenant_id);
|
||||
$this->service->forceDelete($customer);
|
||||
|
||||
return $this->deleted('Customer dihapus permanen');
|
||||
}
|
||||
|
||||
private function scope(Request $request): string
|
||||
{
|
||||
return $request->route()->defaults['customer_scope'] ?? 'orders';
|
||||
}
|
||||
|
||||
private function tenantId(Request $request): ?int
|
||||
{
|
||||
return $request->attributes->get('tenant_id');
|
||||
}
|
||||
|
||||
private function authorizeTenant(Request $request, int $tenantId): void
|
||||
{
|
||||
abort_unless($request->user()->isMasterAdmin() || $tenantId === $this->tenantId($request), 403);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,192 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Notification;
|
||||
|
||||
use App\Http\Controllers\ApiController;
|
||||
use App\Http\Requests\Notification\IndexNotificationRequest;
|
||||
use App\Http\Requests\Notification\SaveNotificationPreferencesRequest;
|
||||
use App\Http\Requests\Notification\StoreCustomerContactRequest;
|
||||
use App\Http\Requests\Notification\StoreNotificationBroadcastRequest;
|
||||
use App\Http\Requests\Notification\StoreNotificationChannelRequest;
|
||||
use App\Http\Requests\Notification\StoreNotificationTemplateRequest;
|
||||
use App\Http\Resources\Notification\NotificationDeliveryResource;
|
||||
use App\Models\Customer;
|
||||
use App\Models\CustomerContact;
|
||||
use App\Models\NotificationBroadcast;
|
||||
use App\Models\NotificationChannel;
|
||||
use App\Models\NotificationEvent;
|
||||
use App\Models\NotificationTemplate;
|
||||
use App\Services\Notification\NotificationService;
|
||||
use Illuminate\Http\Request;
|
||||
|
||||
class NotificationController extends ApiController
|
||||
{
|
||||
public function __construct(protected NotificationService $service) {}
|
||||
|
||||
public function events()
|
||||
{
|
||||
return $this->success(NotificationEvent::where('enabled', true)->orderBy('category')->orderBy('name')->get(), 'Daftar event notifikasi berhasil diambil');
|
||||
}
|
||||
|
||||
public function channels(Request $request)
|
||||
{
|
||||
return $this->success($this->service->channels($this->channel($request), $request->user(), $this->tenantId($request)), 'Konfigurasi channel berhasil diambil');
|
||||
}
|
||||
|
||||
public function storeChannel(StoreNotificationChannelRequest $request)
|
||||
{
|
||||
abort_unless($request->validated('channel') === $this->channel($request), 422);
|
||||
|
||||
return $this->created($this->service->saveChannel($request->validated(), $request->user(), $this->tenantId($request)), 'Konfigurasi channel berhasil dibuat');
|
||||
}
|
||||
|
||||
public function updateChannel(StoreNotificationChannelRequest $request, NotificationChannel $notificationChannel)
|
||||
{
|
||||
$this->authorizeTenant($request, $notificationChannel->tenant_id);
|
||||
abort_unless($notificationChannel->channel === $this->channel($request), 404);
|
||||
|
||||
return $this->updated($this->service->saveChannel($request->validated(), $request->user(), $this->tenantId($request), $notificationChannel), 'Konfigurasi channel berhasil diperbarui');
|
||||
}
|
||||
|
||||
public function testChannel(Request $request, NotificationChannel $notificationChannel)
|
||||
{
|
||||
$this->authorizeTenant($request, $notificationChannel->tenant_id);
|
||||
abort_unless($notificationChannel->channel === $this->channel($request), 404);
|
||||
|
||||
return $this->success($this->service->testChannel($notificationChannel), 'Pengujian channel selesai');
|
||||
}
|
||||
|
||||
public function templates(Request $request)
|
||||
{
|
||||
return $this->success($this->service->templates($this->channel($request), $request->user(), $this->tenantId($request)), 'Daftar template berhasil diambil');
|
||||
}
|
||||
|
||||
public function storeTemplate(StoreNotificationTemplateRequest $request)
|
||||
{
|
||||
abort_unless($request->validated('channel') === $this->channel($request), 422);
|
||||
|
||||
return $this->created($this->service->saveTemplate($request->validated(), $request->user(), $this->tenantId($request)), 'Template berhasil dibuat');
|
||||
}
|
||||
|
||||
public function updateTemplate(StoreNotificationTemplateRequest $request, NotificationTemplate $notificationTemplate)
|
||||
{
|
||||
if ($notificationTemplate->tenant_id) {
|
||||
$this->authorizeTenant($request, $notificationTemplate->tenant_id);
|
||||
}
|
||||
abort_unless($notificationTemplate->channel === $this->channel($request), 404);
|
||||
|
||||
return $this->updated($this->service->saveTemplate($request->validated(), $request->user(), $this->tenantId($request), $notificationTemplate), 'Template berhasil diperbarui');
|
||||
}
|
||||
|
||||
public function preferences(Request $request)
|
||||
{
|
||||
$tenantId = $request->user()->isMasterAdmin() && $request->integer('tenant_id')
|
||||
? $request->integer('tenant_id')
|
||||
: $this->tenantId($request);
|
||||
|
||||
return $this->success($this->service->preferences($request->user(), $tenantId), 'Preference notifikasi berhasil diambil');
|
||||
}
|
||||
|
||||
public function savePreferences(SaveNotificationPreferencesRequest $request)
|
||||
{
|
||||
$this->service->savePreferences($request->validated('preferences'), $request->user(), $this->tenantId($request));
|
||||
|
||||
return $this->updated(null, 'Preference notifikasi berhasil disimpan');
|
||||
}
|
||||
|
||||
public function deliveries(IndexNotificationRequest $request)
|
||||
{
|
||||
return $this->successPaginated(
|
||||
$this->service->deliveries($this->channel($request), $request->validated(), $request->user(), $this->tenantId($request)),
|
||||
NotificationDeliveryResource::class,
|
||||
'Log pesan berhasil diambil',
|
||||
);
|
||||
}
|
||||
|
||||
public function broadcasts(IndexNotificationRequest $request)
|
||||
{
|
||||
return $this->success($this->service->broadcasts($request->validated(), $request->user(), $this->tenantId($request)), 'Daftar pesan siaran berhasil diambil');
|
||||
}
|
||||
|
||||
public function previewBroadcast(StoreNotificationBroadcastRequest $request)
|
||||
{
|
||||
return $this->success($this->service->previewBroadcast($request->validated(), $request->user(), $this->tenantId($request)), 'Preview penerima berhasil dihitung');
|
||||
}
|
||||
|
||||
public function storeBroadcast(StoreNotificationBroadcastRequest $request)
|
||||
{
|
||||
return $this->created($this->service->createBroadcast($request->validated(), $request->user(), $this->tenantId($request)), 'Pesan siaran masuk antrean');
|
||||
}
|
||||
|
||||
public function showBroadcast(Request $request, NotificationBroadcast $notificationBroadcast)
|
||||
{
|
||||
$this->authorizeTenant($request, $notificationBroadcast->tenant_id);
|
||||
|
||||
return $this->success($notificationBroadcast->load(['template', 'channelConfig', 'recipients.customer:id,customer_code,name']), 'Detail siaran berhasil diambil');
|
||||
}
|
||||
|
||||
public function approveBroadcast(Request $request, NotificationBroadcast $notificationBroadcast)
|
||||
{
|
||||
$this->authorizeTenant($request, $notificationBroadcast->tenant_id);
|
||||
|
||||
return $this->updated($this->service->approveBroadcast($notificationBroadcast, $request->user()), 'Pesan siaran berhasil disetujui');
|
||||
}
|
||||
|
||||
public function myNotifications(IndexNotificationRequest $request)
|
||||
{
|
||||
return $this->success($this->service->userNotifications($request->user(), $request->validated()), 'Notifikasi aplikasi berhasil diambil');
|
||||
}
|
||||
|
||||
public function markRead(Request $request, string $uuid)
|
||||
{
|
||||
$notification = $request->user()->userNotifications()->where('uuid', $uuid)->firstOrFail();
|
||||
$notification->update(['read_at' => now()]);
|
||||
|
||||
return $this->updated($notification, 'Notifikasi ditandai sudah dibaca');
|
||||
}
|
||||
|
||||
public function contacts(Request $request, Customer $customer)
|
||||
{
|
||||
$this->authorizeTenant($request, $customer->tenant_id);
|
||||
|
||||
return $this->success($customer->contacts()->orderBy('type')->orderByDesc('is_primary')->get(), 'Kontak notifikasi berhasil diambil');
|
||||
}
|
||||
|
||||
public function storeContact(StoreCustomerContactRequest $request, Customer $customer)
|
||||
{
|
||||
$this->authorizeTenant($request, $customer->tenant_id);
|
||||
|
||||
return $this->created($this->service->saveContact($customer, $request->validated()), 'Kontak notifikasi berhasil dibuat');
|
||||
}
|
||||
|
||||
public function updateContact(StoreCustomerContactRequest $request, Customer $customer, CustomerContact $contact)
|
||||
{
|
||||
$this->authorizeTenant($request, $customer->tenant_id);
|
||||
|
||||
return $this->updated($this->service->saveContact($customer, $request->validated(), $contact), 'Kontak notifikasi berhasil diperbarui');
|
||||
}
|
||||
|
||||
public function destroyContact(Request $request, Customer $customer, CustomerContact $contact)
|
||||
{
|
||||
$this->authorizeTenant($request, $customer->tenant_id);
|
||||
abort_unless((int) $contact->customer_id === (int) $customer->id, 404);
|
||||
$contact->delete();
|
||||
|
||||
return $this->deleted('Kontak notifikasi berhasil dihapus');
|
||||
}
|
||||
|
||||
private function channel(Request $request): string
|
||||
{
|
||||
return $request->route()->defaults['notification_channel'];
|
||||
}
|
||||
|
||||
private function tenantId(Request $request): ?int
|
||||
{
|
||||
return $request->attributes->get('tenant_id');
|
||||
}
|
||||
|
||||
private function authorizeTenant(Request $request, int $tenantId): void
|
||||
{
|
||||
abort_unless($request->user()->isMasterAdmin() || $tenantId === $this->tenantId($request), 403);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Notification;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\NotificationChannel;
|
||||
use App\Models\NotificationDelivery;
|
||||
use App\Models\NotificationWebhookLog;
|
||||
use Illuminate\Http\Request;
|
||||
|
||||
class NotificationWebhookController extends Controller
|
||||
{
|
||||
public function verify(Request $request, NotificationChannel $notificationChannel)
|
||||
{
|
||||
abort_unless(
|
||||
$request->query('hub.mode') === 'subscribe'
|
||||
&& hash_equals((string) data_get($notificationChannel->credentials, 'webhook_verify_token'), (string) $request->query('hub.verify_token')),
|
||||
403,
|
||||
);
|
||||
|
||||
return response($request->query('hub.challenge'), 200);
|
||||
}
|
||||
|
||||
public function handle(Request $request, NotificationChannel $notificationChannel)
|
||||
{
|
||||
$valid = $this->validSignature($request, $notificationChannel);
|
||||
$payload = $request->all();
|
||||
$updates = $this->updates($payload);
|
||||
$log = NotificationWebhookLog::create([
|
||||
'notification_channel_id' => $notificationChannel->id,
|
||||
'provider' => $notificationChannel->provider,
|
||||
'event_type' => data_get($payload, 'entry.0.changes.0.field') ?? data_get($payload, 'event'),
|
||||
'provider_message_id' => data_get($updates, '0.id'),
|
||||
'signature_valid' => $valid,
|
||||
'payload' => $payload,
|
||||
]);
|
||||
abort_unless($valid, 401);
|
||||
|
||||
foreach ($updates as $update) {
|
||||
$delivery = NotificationDelivery::where('provider_message_id', $update['id'] ?? null)->first();
|
||||
if (! $delivery) {
|
||||
continue;
|
||||
}
|
||||
$status = match ($update['status'] ?? null) {
|
||||
'delivered' => 'delivered',
|
||||
'read' => 'read',
|
||||
'failed' => 'failed',
|
||||
default => 'sent',
|
||||
};
|
||||
$delivery->update([
|
||||
'status' => $status,
|
||||
'delivered_at' => $status === 'delivered' ? now() : $delivery->delivered_at,
|
||||
'read_at' => $status === 'read' ? now() : $delivery->read_at,
|
||||
'failed_at' => $status === 'failed' ? now() : $delivery->failed_at,
|
||||
'error_code' => data_get($update, 'errors.0.code'),
|
||||
'error_message' => data_get($update, 'errors.0.title'),
|
||||
]);
|
||||
}
|
||||
$log->update(['processed_at' => now()]);
|
||||
|
||||
return response()->json(['success' => true]);
|
||||
}
|
||||
|
||||
private function validSignature(Request $request, NotificationChannel $channel): bool
|
||||
{
|
||||
if ($channel->channel === 'whatsapp_official') {
|
||||
$secret = data_get($channel->credentials, 'app_secret');
|
||||
$signature = $request->header('X-Hub-Signature-256');
|
||||
|
||||
return $secret && $signature && hash_equals('sha256='.hash_hmac('sha256', $request->getContent(), $secret), $signature);
|
||||
}
|
||||
$secret = data_get($channel->credentials, 'webhook_secret');
|
||||
|
||||
return $secret && hash_equals((string) $secret, (string) $request->header('X-Webhook-Secret'));
|
||||
}
|
||||
|
||||
private function updates(array $payload): array
|
||||
{
|
||||
$statuses = data_get($payload, 'entry.0.changes.0.value.statuses');
|
||||
if (is_array($statuses)) {
|
||||
return $statuses;
|
||||
}
|
||||
|
||||
return [[
|
||||
'id' => data_get($payload, 'message_id') ?? data_get($payload, 'id'),
|
||||
'status' => data_get($payload, 'status') ?? data_get($payload, 'event'),
|
||||
'errors' => data_get($payload, 'errors', []),
|
||||
]];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Payment;
|
||||
|
||||
use App\Http\Controllers\ApiController;
|
||||
use App\Http\Requests\Payment\IndexPaymentRequest;
|
||||
use App\Http\Requests\Payment\SaveTenantPaymentSettingRequest;
|
||||
use App\Http\Requests\Payment\StoreGatewayAccountRequest;
|
||||
use App\Http\Resources\Payment\PaymentTransactionResource;
|
||||
use App\Models\PaymentGatewayAccount;
|
||||
use App\Services\Payment\PaymentGatewayService;
|
||||
use Illuminate\Http\Request;
|
||||
|
||||
class PaymentGatewayController extends ApiController
|
||||
{
|
||||
public function __construct(protected PaymentGatewayService $service) {}
|
||||
|
||||
public function providers()
|
||||
{
|
||||
return $this->success($this->service->providers(), 'Daftar provider berhasil diambil');
|
||||
}
|
||||
|
||||
public function settings(Request $request)
|
||||
{
|
||||
return $this->success($this->service->settings($request->user(), $this->tenantId($request)), 'Pengaturan payment gateway berhasil diambil');
|
||||
}
|
||||
|
||||
public function storeAccount(StoreGatewayAccountRequest $request)
|
||||
{
|
||||
return $this->created($this->service->saveAccount($request->validated(), $request->user(), $this->tenantId($request)), 'Akun gateway berhasil dibuat');
|
||||
}
|
||||
|
||||
public function updateAccount(StoreGatewayAccountRequest $request, PaymentGatewayAccount $account)
|
||||
{
|
||||
return $this->updated($this->service->saveAccount($request->validated(), $request->user(), $this->tenantId($request), $account), 'Akun gateway berhasil diperbarui');
|
||||
}
|
||||
|
||||
public function saveSetting(SaveTenantPaymentSettingRequest $request)
|
||||
{
|
||||
return $this->updated($this->service->saveTenantSetting($request->validated(), $request->user(), $this->tenantId($request)), 'Mode pembayaran tenant berhasil disimpan');
|
||||
}
|
||||
|
||||
public function transactions(IndexPaymentRequest $request)
|
||||
{
|
||||
return $this->successPaginated($this->service->transactions($request->validated(), $request->user(), $this->tenantId($request)), PaymentTransactionResource::class, 'Log pembayaran berhasil diambil');
|
||||
}
|
||||
|
||||
private function tenantId(Request $request): ?int
|
||||
{
|
||||
return $request->attributes->get('tenant_id');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,154 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Topology;
|
||||
|
||||
use App\Http\Controllers\ApiController;
|
||||
use App\Http\Requests\Topology\IndexTopologyRequest;
|
||||
use App\Http\Requests\Topology\StoreCustomerConnectionRequest;
|
||||
use App\Http\Requests\Topology\StoreTopologyRequest;
|
||||
use App\Http\Requests\Topology\UpdateTopologyRequest;
|
||||
use App\Http\Resources\Topology\TopologyResource;
|
||||
use App\Models\Customer;
|
||||
use App\Models\TopologyNode;
|
||||
use App\Models\TopologyPort;
|
||||
use App\Services\Topology\TopologyService;
|
||||
use Illuminate\Http\Request;
|
||||
|
||||
class TopologyController extends ApiController
|
||||
{
|
||||
public function __construct(protected TopologyService $service) {}
|
||||
|
||||
public function index(IndexTopologyRequest $request)
|
||||
{
|
||||
return $this->successPaginated(
|
||||
$this->service->list($this->resource($request), $request->validated(), $request->user(), $this->tenantId($request)),
|
||||
TopologyResource::class,
|
||||
'Daftar topologi berhasil diambil',
|
||||
);
|
||||
}
|
||||
|
||||
public function store(StoreTopologyRequest $request)
|
||||
{
|
||||
return $this->created(
|
||||
new TopologyResource($this->service->create($this->resource($request), $request->validated(), $request->user(), $this->tenantId($request))),
|
||||
'Data topologi berhasil dibuat',
|
||||
);
|
||||
}
|
||||
|
||||
public function show(Request $request, int $id)
|
||||
{
|
||||
$model = $this->service->find($this->resource($request), $id);
|
||||
$this->authorizeModel($request, $model);
|
||||
|
||||
return $this->success(new TopologyResource($model), 'Detail topologi berhasil diambil');
|
||||
}
|
||||
|
||||
public function update(UpdateTopologyRequest $request, int $id)
|
||||
{
|
||||
$resource = $this->resource($request);
|
||||
$model = $this->service->find($resource, $id);
|
||||
$this->authorizeModel($request, $model, true);
|
||||
|
||||
return $this->updated(
|
||||
new TopologyResource($this->service->update($resource, $model, $request->validated(), $request->user())),
|
||||
'Data topologi berhasil diperbarui',
|
||||
);
|
||||
}
|
||||
|
||||
public function destroy(Request $request, int $id)
|
||||
{
|
||||
$resource = $this->resource($request);
|
||||
$model = $this->service->find($resource, $id);
|
||||
$this->authorizeModel($request, $model, true);
|
||||
$this->service->delete($resource, $model);
|
||||
|
||||
return $this->deleted('Data topologi berhasil dihapus');
|
||||
}
|
||||
|
||||
public function options(Request $request)
|
||||
{
|
||||
return $this->success($this->service->options($request->user(), $this->tenantId($request)), 'Pilihan topologi berhasil diambil');
|
||||
}
|
||||
|
||||
public function map(IndexTopologyRequest $request)
|
||||
{
|
||||
return $this->success($this->service->map($request->validated(), $request->user(), $this->tenantId($request)), 'Data peta topologi berhasil diambil');
|
||||
}
|
||||
|
||||
public function ports(Request $request, TopologyNode $node)
|
||||
{
|
||||
$this->authorizeTenant($request, $node->tenant_id);
|
||||
|
||||
return $this->success($this->service->ports($node), 'Daftar port berhasil diambil');
|
||||
}
|
||||
|
||||
public function storePort(StoreTopologyRequest $request, TopologyNode $node)
|
||||
{
|
||||
$this->authorizeTenant($request, $node->tenant_id);
|
||||
|
||||
return $this->created($this->service->createPort($node, $request->validated()), 'Port berhasil dibuat');
|
||||
}
|
||||
|
||||
public function updatePort(UpdateTopologyRequest $request, TopologyPort $port)
|
||||
{
|
||||
$this->authorizeTenant($request, $port->tenant_id);
|
||||
|
||||
return $this->updated($this->service->updatePort($port, $request->validated()), 'Port berhasil diperbarui');
|
||||
}
|
||||
|
||||
public function destroyPort(Request $request, TopologyPort $port)
|
||||
{
|
||||
$this->authorizeTenant($request, $port->tenant_id);
|
||||
$this->service->deletePort($port);
|
||||
|
||||
return $this->deleted('Port berhasil dihapus');
|
||||
}
|
||||
|
||||
public function trace(Request $request, TopologyNode $node, string $direction)
|
||||
{
|
||||
abort_unless(in_array($direction, ['upstream', 'downstream'], true), 404);
|
||||
$this->authorizeTenant($request, $node->tenant_id);
|
||||
|
||||
return $this->success($this->service->trace($node, $direction), 'Jalur topologi berhasil ditelusuri');
|
||||
}
|
||||
|
||||
public function connectCustomer(StoreCustomerConnectionRequest $request)
|
||||
{
|
||||
return $this->created(
|
||||
$this->service->connectCustomer($request->validated(), $request->user(), $this->tenantId($request)),
|
||||
'Customer berhasil dihubungkan ke ODP',
|
||||
);
|
||||
}
|
||||
|
||||
public function customerPath(Request $request, Customer $customer)
|
||||
{
|
||||
$this->authorizeTenant($request, $customer->tenant_id);
|
||||
|
||||
return $this->success($this->service->customerPath($customer), 'Jalur customer berhasil ditelusuri');
|
||||
}
|
||||
|
||||
private function resource(Request $request): string
|
||||
{
|
||||
return $request->route()->defaults['topology_resource'];
|
||||
}
|
||||
|
||||
private function tenantId(Request $request): ?int
|
||||
{
|
||||
return $request->attributes->get('tenant_id');
|
||||
}
|
||||
|
||||
private function authorizeModel(Request $request, $model, bool $write = false): void
|
||||
{
|
||||
if ($model->tenant_id === null) {
|
||||
abort_if($write && ! $request->user()->isMasterAdmin(), 403);
|
||||
|
||||
return;
|
||||
}
|
||||
$this->authorizeTenant($request, $model->tenant_id);
|
||||
}
|
||||
|
||||
private function authorizeTenant(Request $request, int $tenantId): void
|
||||
{
|
||||
abort_unless($request->user()->isMasterAdmin() || $tenantId === $this->tenantId($request), 403);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Wallet;
|
||||
|
||||
use App\Http\Controllers\ApiController;
|
||||
use App\Http\Requests\Wallet\StoreWithdrawalRequest;
|
||||
use App\Http\Requests\Wallet\TransferWalletRequest;
|
||||
use App\Models\Wallet;
|
||||
use App\Services\Wallet\WalletService;
|
||||
use Illuminate\Http\Request;
|
||||
|
||||
class WalletController extends ApiController
|
||||
{
|
||||
public function __construct(protected WalletService $service) {}
|
||||
|
||||
public function index(Request $request)
|
||||
{
|
||||
return $this->success($this->service->wallets($request->user(), $this->tenantId($request)), 'Daftar dompet berhasil diambil');
|
||||
}
|
||||
|
||||
public function ledger(Request $request, Wallet $wallet)
|
||||
{
|
||||
return $this->success($this->service->ledger($wallet, $request->user(), $this->tenantId($request), $request->validate(['per_page' => ['nullable', 'integer', 'min:1', 'max:100']])), 'Mutasi dompet berhasil diambil');
|
||||
}
|
||||
|
||||
public function transfer(TransferWalletRequest $request, Wallet $wallet)
|
||||
{
|
||||
return $this->created($this->service->transfer($wallet, Wallet::findOrFail($request->integer('destination_wallet_id')), $request->float('amount'), $request->user(), $this->tenantId($request)), 'Saldo berhasil dipindahkan');
|
||||
}
|
||||
|
||||
public function withdraw(StoreWithdrawalRequest $request, Wallet $wallet)
|
||||
{
|
||||
return $this->created($this->service->requestWithdrawal($wallet, $request->validated(), $request->user(), $this->tenantId($request)), 'Permintaan penarikan berhasil dibuat');
|
||||
}
|
||||
|
||||
private function tenantId(Request $request): ?int
|
||||
{
|
||||
return $request->attributes->get('tenant_id');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Requests\Billing;
|
||||
|
||||
use Illuminate\Foundation\Http\FormRequest;
|
||||
use Illuminate\Validation\Rule;
|
||||
|
||||
class IndexBillingRequest extends FormRequest
|
||||
{
|
||||
public function authorize(): bool
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
public function rules(): array
|
||||
{
|
||||
return [
|
||||
'search' => ['nullable', 'string', 'max:255'],
|
||||
'status' => ['nullable', 'string', 'max:30'],
|
||||
'date_from' => ['nullable', 'date'],
|
||||
'date_to' => ['nullable', 'date', 'after_or_equal:date_from'],
|
||||
'page' => ['nullable', 'integer', 'min:1'],
|
||||
'per_page' => ['nullable', 'integer', 'min:1', 'max:100'],
|
||||
'sort_by' => ['nullable', Rule::in(['id', 'name', 'invoice_number', 'issue_date', 'isolation_date', 'total', 'balance', 'status', 'created_at', 'paid_at'])],
|
||||
'sort_direction' => ['nullable', Rule::in(['asc', 'desc'])],
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Requests\Billing;
|
||||
|
||||
use Illuminate\Foundation\Http\FormRequest;
|
||||
|
||||
class PayInvoiceRequest extends FormRequest
|
||||
{
|
||||
public function authorize(): bool
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
public function rules(): array
|
||||
{
|
||||
return [
|
||||
'amount' => ['required', 'numeric', 'gt:0'],
|
||||
'payment_method' => ['nullable', 'string', 'max:50'],
|
||||
'payment_reference' => ['nullable', 'string', 'max:255'],
|
||||
'paid_at' => ['nullable', 'date'],
|
||||
'notes' => ['nullable', 'string'],
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Requests\Billing;
|
||||
|
||||
use Illuminate\Foundation\Http\FormRequest;
|
||||
use Illuminate\Validation\Rule;
|
||||
|
||||
class StoreBillingProfileRequest extends FormRequest
|
||||
{
|
||||
public function authorize(): bool
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
public function rules(): array
|
||||
{
|
||||
return [
|
||||
'tenant_id' => ['nullable', 'integer', 'exists:tenants,id'],
|
||||
'name' => ['required', 'string', 'max:255'],
|
||||
'schedule_type' => ['required', Rule::in(['fixed_date', 'installation_date'])],
|
||||
'invoice_day' => ['required_if:schedule_type,fixed_date', 'nullable', 'integer', 'between:1,28'],
|
||||
'send_day' => ['required_if:schedule_type,fixed_date', 'nullable', 'integer', 'between:1,28', 'gte:invoice_day'],
|
||||
'warning_day' => ['required_if:schedule_type,fixed_date', 'nullable', 'integer', 'between:1,28', 'gte:send_day'],
|
||||
'isolation_day' => ['required_if:schedule_type,fixed_date', 'nullable', 'integer', 'between:1,28', 'gte:warning_day'],
|
||||
'invoice_days_before' => ['required_if:schedule_type,installation_date', 'nullable', 'integer', 'between:0,90'],
|
||||
'send_days_before' => ['required_if:schedule_type,installation_date', 'nullable', 'integer', 'between:0,90', 'lte:invoice_days_before'],
|
||||
'warning_days_before' => ['required_if:schedule_type,installation_date', 'nullable', 'integer', 'between:0,90', 'lte:send_days_before'],
|
||||
'isolation_days_after' => ['required_if:schedule_type,installation_date', 'nullable', 'integer', 'between:0,90'],
|
||||
'tax_type' => ['required', Rule::in(['inclusive', 'exclusive', 'non_tax'])],
|
||||
'tax_rate' => ['nullable', 'numeric', 'between:0,100'],
|
||||
'status' => ['nullable', Rule::in(['active', 'inactive'])],
|
||||
'notes' => ['nullable', 'string'],
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Requests\Billing;
|
||||
|
||||
use Illuminate\Foundation\Http\FormRequest;
|
||||
use Illuminate\Validation\Rule;
|
||||
|
||||
class StoreInvoiceRequest extends FormRequest
|
||||
{
|
||||
public function authorize(): bool
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
public function rules(): array
|
||||
{
|
||||
return [
|
||||
'tenant_id' => ['nullable', 'integer', 'exists:tenants,id'],
|
||||
'customer_id' => ['required', 'integer', 'exists:customers,id'],
|
||||
'invoice_number' => ['nullable', 'string', 'max:100'],
|
||||
'period_start' => ['required', 'date'],
|
||||
'period_end' => ['required', 'date', 'after_or_equal:period_start'],
|
||||
'issue_date' => ['required', 'date'],
|
||||
'send_date' => ['nullable', 'date', 'after_or_equal:issue_date'],
|
||||
'warning_date' => ['nullable', 'date', 'after_or_equal:send_date'],
|
||||
'isolation_date' => ['nullable', 'date', 'after_or_equal:warning_date'],
|
||||
'subtotal' => ['required', 'numeric', 'min:0'],
|
||||
'notes' => ['nullable', 'string'],
|
||||
'metadata' => ['nullable', 'array'],
|
||||
'status' => ['nullable', Rule::in(['draft', 'published', 'sent', 'warning', 'overdue', 'paid', 'cancelled'])],
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Requests\Billing;
|
||||
|
||||
class UpdateBillingProfileRequest extends StoreBillingProfileRequest
|
||||
{
|
||||
public function rules(): array
|
||||
{
|
||||
return collect(parent::rules())->map(function ($rules) {
|
||||
$rules = is_array($rules) ? $rules : [$rules];
|
||||
if (($index = array_search('required', $rules, true)) !== false) {
|
||||
$rules[$index] = 'sometimes';
|
||||
}
|
||||
|
||||
return $rules;
|
||||
})->all();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Requests\Billing;
|
||||
|
||||
class UpdateInvoiceRequest extends StoreInvoiceRequest
|
||||
{
|
||||
public function rules(): array
|
||||
{
|
||||
return collect(parent::rules())->map(function ($rules) {
|
||||
$rules = is_array($rules) ? $rules : [$rules];
|
||||
if (($index = array_search('required', $rules, true)) !== false) {
|
||||
$rules[$index] = 'sometimes';
|
||||
}
|
||||
|
||||
return $rules;
|
||||
})->all();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Requests\Customer;
|
||||
|
||||
use Illuminate\Foundation\Http\FormRequest;
|
||||
|
||||
class ActivateCustomerRequest extends FormRequest
|
||||
{
|
||||
public function authorize(): bool
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
public function rules(): array
|
||||
{
|
||||
return [
|
||||
'package_profile_id' => ['required', 'integer', 'exists:nas_package_profiles,id'],
|
||||
'nas_mikrotik_id' => ['required', 'integer', 'exists:nas_mikrotiks,id'],
|
||||
'billing_profile_id' => ['required', 'integer', 'exists:billing_profiles,id'],
|
||||
'topology_id' => ['nullable', 'integer', 'min:1'],
|
||||
'external_username' => ['nullable', 'string', 'max:255'],
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Requests\Customer;
|
||||
|
||||
use Illuminate\Foundation\Http\FormRequest;
|
||||
use Illuminate\Validation\Rule;
|
||||
|
||||
class IndexCustomerRequest extends FormRequest
|
||||
{
|
||||
public function authorize(): bool
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
public function rules(): array
|
||||
{
|
||||
return [
|
||||
'search' => ['nullable', 'string', 'max:255'],
|
||||
'source' => ['nullable', Rule::in(['manual', 'mikrotik', 'radius'])],
|
||||
'page' => ['nullable', 'integer', 'min:1'],
|
||||
'per_page' => ['nullable', 'integer', 'min:1', 'max:100'],
|
||||
'sort_by' => ['nullable', Rule::in(['id', 'customer_code', 'name', 'email', 'whatsapp_number', 'status', 'order_stage', 'created_at', 'activated_at'])],
|
||||
'sort_direction' => ['nullable', Rule::in(['asc', 'desc'])],
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Requests\Customer;
|
||||
|
||||
use Illuminate\Foundation\Http\FormRequest;
|
||||
use Illuminate\Validation\Rule;
|
||||
|
||||
class StoreCustomerRequest extends FormRequest
|
||||
{
|
||||
public function authorize(): bool
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
public function rules(): array
|
||||
{
|
||||
return [
|
||||
'tenant_id' => ['nullable', 'integer', 'exists:tenants,id'],
|
||||
'user_id' => ['nullable', 'integer', 'exists:users,id'],
|
||||
'customer_code' => ['nullable', 'string', 'max:100'],
|
||||
'name' => ['required', 'string', 'max:255'],
|
||||
'identity_number' => ['nullable', 'string', 'max:100'],
|
||||
'email' => ['nullable', 'email', 'max:255'],
|
||||
'whatsapp_number' => ['nullable', 'string', 'max:50'],
|
||||
'installation_address' => ['nullable', 'string'],
|
||||
'provinsi_id' => ['nullable', 'integer', 'exists:wilayah_provinsi,id'],
|
||||
'kabupaten_id' => ['nullable', 'integer', 'exists:wilayah_kabupaten,id'],
|
||||
'kecamatan_id' => ['nullable', 'integer', 'exists:wilayah_kecamatan,id'],
|
||||
'desa_id' => ['nullable', 'integer', 'exists:wilayah_desa,id'],
|
||||
'latitude' => ['nullable', 'numeric', 'between:-90,90'],
|
||||
'longitude' => ['nullable', 'numeric', 'between:-180,180'],
|
||||
'package_profile_id' => ['nullable', 'integer', 'exists:nas_package_profiles,id'],
|
||||
'nas_mikrotik_id' => ['nullable', 'integer', 'exists:nas_mikrotiks,id'],
|
||||
'billing_profile_id' => ['nullable', 'integer', 'min:1'],
|
||||
'topology_id' => ['nullable', 'integer', 'min:1'],
|
||||
'external_username' => ['nullable', 'string', 'max:255'],
|
||||
'source' => ['nullable', Rule::in(['manual', 'mikrotik', 'radius'])],
|
||||
'order_stage' => ['nullable', Rule::in(['registration', 'data_completion', 'ready_activation', 'activation_failed'])],
|
||||
'notes' => ['nullable', 'string'],
|
||||
'images' => ['nullable', 'array', 'max:20'],
|
||||
'images.*.stored_file_id' => ['required', 'integer', 'exists:stored_files,id'],
|
||||
'images.*.category' => ['required', Rule::in(['house', 'installation', 'modem', 'odp', 'cable', 'identity', 'other'])],
|
||||
'images.*.caption' => ['nullable', 'string', 'max:255'],
|
||||
'images.*.is_cover' => ['nullable', 'boolean'],
|
||||
'images.*.sort_order' => ['nullable', 'integer', 'min:0', 'max:1000'],
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Requests\Customer;
|
||||
|
||||
class UpdateCustomerRequest extends StoreCustomerRequest
|
||||
{
|
||||
public function rules(): array
|
||||
{
|
||||
$rules = parent::rules();
|
||||
$rules['name'] = ['sometimes', 'required', 'string', 'max:255'];
|
||||
|
||||
return $rules;
|
||||
}
|
||||
}
|
||||
@@ -13,11 +13,13 @@ class StoreFileRequest extends FormRequest
|
||||
|
||||
public function rules(): array
|
||||
{
|
||||
$maxSize = $this->input('category') === 'customer-image' ? 500 : 20480;
|
||||
|
||||
return [
|
||||
'file' => [
|
||||
'required',
|
||||
'file',
|
||||
'max:20480',
|
||||
"max:{$maxSize}",
|
||||
'mimes:jpg,jpeg,png,webp,gif,pdf,doc,docx,xls,xlsx,csv,txt,zip',
|
||||
],
|
||||
'category' => ['nullable', 'string', 'max:80', 'regex:/^[a-z0-9_-]+$/'],
|
||||
@@ -27,7 +29,9 @@ class StoreFileRequest extends FormRequest
|
||||
public function messages(): array
|
||||
{
|
||||
return [
|
||||
'file.max' => 'Ukuran file maksimal 20 MB.',
|
||||
'file.max' => $this->input('category') === 'customer-image'
|
||||
? 'Gambar customer maksimal 500 KB setelah kompresi.'
|
||||
: 'Ukuran file maksimal 20 MB.',
|
||||
'file.mimes' => 'Jenis file tidak didukung.',
|
||||
'category.regex' => 'Kategori hanya boleh berisi huruf kecil, angka, garis bawah, atau tanda hubung.',
|
||||
];
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Requests\Notification;
|
||||
|
||||
use Illuminate\Foundation\Http\FormRequest;
|
||||
|
||||
class IndexNotificationRequest extends FormRequest
|
||||
{
|
||||
public function authorize(): bool
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
public function rules(): array
|
||||
{
|
||||
return [
|
||||
'search' => ['nullable', 'string', 'max:255'],
|
||||
'status' => ['nullable', 'string', 'max:30'],
|
||||
'unread' => ['nullable', 'boolean'],
|
||||
'page' => ['nullable', 'integer', 'min:1'],
|
||||
'per_page' => ['nullable', 'integer', 'min:1', 'max:100'],
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Requests\Notification;
|
||||
|
||||
use Illuminate\Foundation\Http\FormRequest;
|
||||
use Illuminate\Validation\Rule;
|
||||
|
||||
class SaveNotificationPreferencesRequest extends FormRequest
|
||||
{
|
||||
public function authorize(): bool
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
public function rules(): array
|
||||
{
|
||||
return [
|
||||
'preferences' => ['required', 'array'],
|
||||
'preferences.*.tenant_id' => ['nullable', 'integer', 'exists:tenants,id'],
|
||||
'preferences.*.notification_event_id' => ['required', 'integer', 'exists:notification_events,id'],
|
||||
'preferences.*.notification_channel_id' => ['nullable', 'integer', 'exists:notification_channels,id'],
|
||||
'preferences.*.channel' => ['required', Rule::in(['system', 'whatsapp_official', 'whatsapp_unofficial', 'email', 'telegram'])],
|
||||
'preferences.*.enabled' => ['required', 'boolean'],
|
||||
'preferences.*.priority' => ['nullable', 'integer', 'between:1,20'],
|
||||
'preferences.*.send_delay_minutes' => ['nullable', 'integer', 'between:0,10080'],
|
||||
'preferences.*.quiet_hours_start' => ['nullable', 'date_format:H:i'],
|
||||
'preferences.*.quiet_hours_end' => ['nullable', 'date_format:H:i'],
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Requests\Notification;
|
||||
|
||||
use Illuminate\Foundation\Http\FormRequest;
|
||||
use Illuminate\Validation\Rule;
|
||||
|
||||
class StoreCustomerContactRequest extends FormRequest
|
||||
{
|
||||
public function authorize(): bool
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
public function rules(): array
|
||||
{
|
||||
return [
|
||||
'type' => ['required', Rule::in(['email', 'whatsapp', 'telegram'])],
|
||||
'value' => ['required', 'string', 'max:255'],
|
||||
'label' => ['nullable', 'string', 'max:100'],
|
||||
'is_primary' => ['nullable', 'boolean'],
|
||||
'is_verified' => ['nullable', 'boolean'],
|
||||
'can_receive_transactional' => ['nullable', 'boolean'],
|
||||
'can_receive_broadcast' => ['nullable', 'boolean'],
|
||||
'opted_out_at' => ['nullable', 'date'],
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Requests\Notification;
|
||||
|
||||
use Illuminate\Foundation\Http\FormRequest;
|
||||
use Illuminate\Validation\Rule;
|
||||
|
||||
class StoreNotificationBroadcastRequest extends FormRequest
|
||||
{
|
||||
public function authorize(): bool
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
public function rules(): array
|
||||
{
|
||||
return [
|
||||
'tenant_id' => ['nullable', 'integer', 'exists:tenants,id'],
|
||||
'name' => ['required_without:preview', 'string', 'max:255'],
|
||||
'channel' => ['required', Rule::in(['system', 'whatsapp_official', 'whatsapp_unofficial', 'email', 'telegram'])],
|
||||
'notification_template_id' => ['nullable', 'integer', 'exists:notification_templates,id'],
|
||||
'notification_channel_id' => ['nullable', 'integer', 'exists:notification_channels,id'],
|
||||
'subject' => ['nullable', 'string', 'max:255'],
|
||||
'body' => ['required_without:notification_template_id', 'nullable', 'string', 'max:20000'],
|
||||
'scheduled_at' => ['nullable', 'date'],
|
||||
'filters' => ['nullable', 'array'],
|
||||
'filters.customer_status' => ['nullable', 'array'],
|
||||
'filters.customer_status.*' => [Rule::in(['order', 'active', 'inactive', 'unmanaged'])],
|
||||
'filters.source' => ['nullable', Rule::in(['manual', 'nas'])],
|
||||
'filters.package_profile_id' => ['nullable', 'integer'],
|
||||
'filters.billing_profile_id' => ['nullable', 'integer'],
|
||||
'filters.topology_node_id' => ['nullable', 'integer', 'exists:topology_nodes,id'],
|
||||
'filters.provinsi_id' => ['nullable', 'integer'],
|
||||
'filters.kabupaten_id' => ['nullable', 'integer'],
|
||||
'filters.kecamatan_id' => ['nullable', 'integer'],
|
||||
'filters.desa_id' => ['nullable', 'integer'],
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Requests\Notification;
|
||||
|
||||
use Illuminate\Foundation\Http\FormRequest;
|
||||
use Illuminate\Validation\Rule;
|
||||
|
||||
class StoreNotificationChannelRequest extends FormRequest
|
||||
{
|
||||
public function authorize(): bool
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
public function rules(): array
|
||||
{
|
||||
return [
|
||||
'tenant_id' => ['nullable', 'integer', 'exists:tenants,id'],
|
||||
'channel' => ['required', Rule::in(['system', 'whatsapp_official', 'whatsapp_unofficial', 'email', 'telegram'])],
|
||||
'provider' => ['nullable', 'string', 'max:50'],
|
||||
'name' => ['required', 'string', 'max:255'],
|
||||
'enabled' => ['nullable', 'boolean'],
|
||||
'is_default' => ['nullable', 'boolean'],
|
||||
'credentials' => ['nullable', 'array'],
|
||||
'credentials.*' => ['nullable', 'string', 'max:4000'],
|
||||
'settings' => ['nullable', 'array'],
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Requests\Notification;
|
||||
|
||||
use Illuminate\Foundation\Http\FormRequest;
|
||||
use Illuminate\Validation\Rule;
|
||||
|
||||
class StoreNotificationTemplateRequest extends FormRequest
|
||||
{
|
||||
public function authorize(): bool
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
public function rules(): array
|
||||
{
|
||||
return [
|
||||
'tenant_id' => ['nullable', 'integer', 'exists:tenants,id'],
|
||||
'notification_event_id' => ['required', 'integer', 'exists:notification_events,id'],
|
||||
'notification_channel_id' => ['nullable', 'integer', 'exists:notification_channels,id'],
|
||||
'channel' => ['required', Rule::in(['system', 'whatsapp_official', 'whatsapp_unofficial', 'email', 'telegram'])],
|
||||
'name' => ['required', 'string', 'max:255'],
|
||||
'subject' => ['nullable', 'string', 'max:255'],
|
||||
'body' => ['required', 'string', 'max:20000'],
|
||||
'provider_template_name' => ['nullable', 'string', 'max:255'],
|
||||
'provider_template_id' => ['nullable', 'string', 'max:255'],
|
||||
'language' => ['nullable', 'string', 'max:10'],
|
||||
'approval_status' => ['nullable', Rule::in(['not_required', 'pending', 'approved', 'rejected'])],
|
||||
'enabled' => ['nullable', 'boolean'],
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Requests\Payment;
|
||||
|
||||
use Illuminate\Foundation\Http\FormRequest;
|
||||
use Illuminate\Validation\Rule;
|
||||
|
||||
class IndexPaymentRequest extends FormRequest
|
||||
{
|
||||
public function authorize(): bool
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
public function rules(): array
|
||||
{
|
||||
return [
|
||||
'search' => ['nullable', 'string', 'max:100'], 'status' => ['nullable', 'string', 'max:30'],
|
||||
'provider_code' => ['nullable', 'string', 'max:50'], 'purpose' => ['nullable', 'string', 'max:30'],
|
||||
'date_from' => ['nullable', 'date'], 'date_to' => ['nullable', 'date', 'after_or_equal:date_from'],
|
||||
'per_page' => ['nullable', 'integer', 'min:1', 'max:100'],
|
||||
'sort_by' => ['nullable', Rule::in(['id', 'transaction_number', 'amount', 'status', 'created_at', 'paid_at'])],
|
||||
'sort_direction' => ['nullable', Rule::in(['asc', 'desc'])],
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Requests\Payment;
|
||||
|
||||
use Illuminate\Foundation\Http\FormRequest;
|
||||
use Illuminate\Validation\Rule;
|
||||
|
||||
class SaveTenantPaymentSettingRequest extends FormRequest
|
||||
{
|
||||
public function authorize(): bool
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
public function rules(): array
|
||||
{
|
||||
return [
|
||||
'tenant_id' => ['nullable', 'integer', 'exists:tenants,id'],
|
||||
'payment_mode' => ['required', Rule::in(['disabled', 'tenant_gateway', 'platform_gateway'])],
|
||||
'tenant_gateway_account_id' => ['nullable', 'integer', 'exists:payment_gateway_accounts,id'],
|
||||
'platform_gateway_account_id' => ['nullable', 'integer', 'exists:payment_gateway_accounts,id'],
|
||||
'fee_bearer' => ['required', Rule::in(['customer', 'tenant', 'platform'])],
|
||||
'default_expiry_minutes' => ['required', 'integer', 'min:5', 'max:10080'],
|
||||
'enabled_methods' => ['nullable', 'array'],
|
||||
'enabled_methods.*' => ['string', 'max:50'],
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Requests\Payment;
|
||||
|
||||
use Illuminate\Foundation\Http\FormRequest;
|
||||
use Illuminate\Validation\Rule;
|
||||
|
||||
class StoreGatewayAccountRequest extends FormRequest
|
||||
{
|
||||
public function authorize(): bool
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
public function rules(): array
|
||||
{
|
||||
return [
|
||||
'payment_provider_id' => ['required', 'integer', 'exists:payment_providers,id'],
|
||||
'tenant_id' => ['nullable', 'integer', 'exists:tenants,id'],
|
||||
'scope' => ['required', Rule::in(['tenant', 'platform'])],
|
||||
'name' => ['required', 'string', 'max:150'],
|
||||
'environment' => ['required', Rule::in(['sandbox', 'production'])],
|
||||
'credentials' => ['nullable', 'array'],
|
||||
'credentials.*' => ['nullable', 'string', 'max:4000'],
|
||||
'webhook_secret' => ['nullable', 'string', 'max:4000'],
|
||||
'settings' => ['nullable', 'array'],
|
||||
'enabled' => ['sometimes', 'boolean'],
|
||||
'is_default' => ['sometimes', 'boolean'],
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Requests\Topology;
|
||||
|
||||
use Illuminate\Foundation\Http\FormRequest;
|
||||
use Illuminate\Validation\Rule;
|
||||
|
||||
class IndexTopologyRequest extends FormRequest
|
||||
{
|
||||
public function authorize(): bool
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
public function rules(): array
|
||||
{
|
||||
return [
|
||||
'search' => ['nullable', 'string', 'max:255'],
|
||||
'status' => ['nullable', 'string', 'max:30'],
|
||||
'device_type_id' => ['nullable', 'integer', 'exists:topology_device_types,id'],
|
||||
'link_type' => ['nullable', Rule::in(['fiber', 'wireless', 'ethernet', 'logical'])],
|
||||
'bbox' => ['nullable', 'regex:/^-?\d+(\.\d+)?,-?\d+(\.\d+)?,-?\d+(\.\d+)?,-?\d+(\.\d+)?$/'],
|
||||
'page' => ['nullable', 'integer', 'min:1'],
|
||||
'per_page' => ['nullable', 'integer', 'min:1', 'max:100'],
|
||||
'sort_by' => ['nullable', Rule::in(['id', 'code', 'name', 'status', 'category', 'link_type', 'installed_at', 'created_at'])],
|
||||
'sort_direction' => ['nullable', Rule::in(['asc', 'desc'])],
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Requests\Topology;
|
||||
|
||||
use Illuminate\Foundation\Http\FormRequest;
|
||||
use Illuminate\Validation\Rule;
|
||||
|
||||
class StoreCustomerConnectionRequest extends FormRequest
|
||||
{
|
||||
public function authorize(): bool
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
public function rules(): array
|
||||
{
|
||||
return [
|
||||
'customer_id' => ['required', 'integer', 'exists:customers,id'],
|
||||
'odp_node_id' => ['required', 'integer', 'exists:topology_nodes,id'],
|
||||
'odp_port_id' => ['nullable', 'integer', 'exists:topology_ports,id'],
|
||||
'drop_cable_link_id' => ['nullable', 'integer', 'exists:topology_links,id'],
|
||||
'connected_at' => ['nullable', 'date'],
|
||||
'status' => ['nullable', Rule::in(['active', 'inactive'])],
|
||||
'metadata' => ['nullable', 'array'],
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,89 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Requests\Topology;
|
||||
|
||||
use Illuminate\Foundation\Http\FormRequest;
|
||||
use Illuminate\Validation\Rule;
|
||||
|
||||
class StoreTopologyRequest extends FormRequest
|
||||
{
|
||||
public function authorize(): bool
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
public function rules(): array
|
||||
{
|
||||
return match ($this->route()->defaults['topology_resource'] ?? null) {
|
||||
'device-type' => [
|
||||
...$this->tenantRule(),
|
||||
'code' => ['required', 'string', 'max:50'],
|
||||
'name' => ['required', 'string', 'max:255'],
|
||||
'category' => ['required', Rule::in(['site', 'termination', 'closure', 'distribution', 'splitter', 'other'])],
|
||||
'can_have_ports' => ['nullable', 'boolean'],
|
||||
'can_have_splitters' => ['nullable', 'boolean'],
|
||||
'icon' => ['nullable', 'string', 'max:100'],
|
||||
'color' => ['nullable', 'regex:/^#[0-9a-fA-F]{6}$/'],
|
||||
'status' => ['nullable', Rule::in(['active', 'inactive'])],
|
||||
],
|
||||
'node' => [
|
||||
...$this->tenantRule(),
|
||||
'device_type_id' => ['required', 'integer', 'exists:topology_device_types,id'],
|
||||
'parent_node_id' => ['nullable', 'integer', 'exists:topology_nodes,id'],
|
||||
'code' => ['required', 'string', 'max:100'],
|
||||
'name' => ['required', 'string', 'max:255'],
|
||||
'description' => ['nullable', 'string'],
|
||||
'status' => ['nullable', Rule::in(['active', 'inactive', 'maintenance', 'damaged'])],
|
||||
'latitude' => ['required', 'numeric', 'between:-90,90'],
|
||||
'longitude' => ['required', 'numeric', 'between:-180,180'],
|
||||
'address' => ['nullable', 'string'],
|
||||
'provinsi_id' => ['nullable', 'integer', 'exists:wilayah_provinsi,id'],
|
||||
'kabupaten_id' => ['nullable', 'integer', 'exists:wilayah_kabupaten,id'],
|
||||
'kecamatan_id' => ['nullable', 'integer', 'exists:wilayah_kecamatan,id'],
|
||||
'desa_id' => ['nullable', 'integer', 'exists:wilayah_desa,id'],
|
||||
'capacity' => ['nullable', 'integer', 'min:0'],
|
||||
'installed_at' => ['nullable', 'date'],
|
||||
'diagram_x' => ['nullable', 'numeric'],
|
||||
'diagram_y' => ['nullable', 'numeric'],
|
||||
'metadata' => ['nullable', 'array'],
|
||||
],
|
||||
'link' => [
|
||||
...$this->tenantRule(),
|
||||
'source_node_id' => ['required', 'integer', 'exists:topology_nodes,id', 'different:target_node_id'],
|
||||
'source_port_id' => ['nullable', 'integer', 'exists:topology_ports,id'],
|
||||
'target_node_id' => ['required', 'integer', 'exists:topology_nodes,id'],
|
||||
'target_port_id' => ['nullable', 'integer', 'exists:topology_ports,id'],
|
||||
'code' => ['required', 'string', 'max:100'],
|
||||
'name' => ['required', 'string', 'max:255'],
|
||||
'link_type' => ['required', Rule::in(['fiber', 'wireless', 'ethernet', 'logical'])],
|
||||
'fiber_type' => ['nullable', 'string', 'max:50'],
|
||||
'core_count' => ['nullable', 'integer', 'min:1'],
|
||||
'cable_length' => ['nullable', 'numeric', 'min:0'],
|
||||
'measured_length' => ['nullable', 'numeric', 'min:0'],
|
||||
'path_geojson' => ['nullable', 'array'],
|
||||
'path_geojson.type' => ['required_with:path_geojson', Rule::in(['LineString'])],
|
||||
'path_geojson.coordinates' => ['required_with:path_geojson', 'array', 'min:2'],
|
||||
'path_geojson.coordinates.*' => ['array', 'size:2'],
|
||||
'path_geojson.coordinates.*.*' => ['numeric'],
|
||||
'status' => ['nullable', Rule::in(['active', 'inactive', 'maintenance', 'damaged'])],
|
||||
'installed_at' => ['nullable', 'date'],
|
||||
'metadata' => ['nullable', 'array'],
|
||||
],
|
||||
'port' => [
|
||||
'name' => ['required', 'string', 'max:100'],
|
||||
'port_number' => ['required', 'integer', 'min:1'],
|
||||
'port_type' => ['required', Rule::in(['fiber', 'ethernet', 'pon', 'uplink', 'splitter', 'other'])],
|
||||
'direction' => ['required', Rule::in(['input', 'output', 'bidirectional'])],
|
||||
'capacity' => ['nullable', 'integer', 'min:1'],
|
||||
'status' => ['nullable', Rule::in(['available', 'used', 'reserved', 'damaged'])],
|
||||
'metadata' => ['nullable', 'array'],
|
||||
],
|
||||
default => [],
|
||||
};
|
||||
}
|
||||
|
||||
private function tenantRule(): array
|
||||
{
|
||||
return ['tenant_id' => ['nullable', 'integer', 'exists:tenants,id']];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Requests\Topology;
|
||||
|
||||
class UpdateTopologyRequest extends StoreTopologyRequest
|
||||
{
|
||||
public function rules(): array
|
||||
{
|
||||
return collect(parent::rules())->map(function ($rules) {
|
||||
$rules = is_array($rules) ? $rules : [$rules];
|
||||
if (($index = array_search('required', $rules, true)) !== false) {
|
||||
$rules[$index] = 'sometimes';
|
||||
}
|
||||
|
||||
return $rules;
|
||||
})->all();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Requests\Wallet;
|
||||
|
||||
use Illuminate\Foundation\Http\FormRequest;
|
||||
|
||||
class StoreWithdrawalRequest extends FormRequest
|
||||
{
|
||||
public function authorize(): bool
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
public function rules(): array
|
||||
{
|
||||
return [
|
||||
'amount' => ['required', 'numeric', 'min:10000'], 'fee' => ['nullable', 'numeric', 'min:0'],
|
||||
'bank_code' => ['required', 'string', 'max:30'], 'account_number' => ['required', 'string', 'max:50'],
|
||||
'account_name' => ['required', 'string', 'max:150'],
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Requests\Wallet;
|
||||
|
||||
use Illuminate\Foundation\Http\FormRequest;
|
||||
|
||||
class TransferWalletRequest extends FormRequest
|
||||
{
|
||||
public function authorize(): bool
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
public function rules(): array
|
||||
{
|
||||
return ['destination_wallet_id' => ['required', 'integer', 'exists:wallets,id'], 'amount' => ['required', 'numeric', 'min:1']];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Resources\Billing;
|
||||
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Http\Resources\Json\JsonResource;
|
||||
|
||||
class BillingProfileResource extends JsonResource
|
||||
{
|
||||
public function toArray(Request $request): array
|
||||
{
|
||||
return [...parent::toArray($request), 'tenant' => $this->whenLoaded('tenant')];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Resources\Billing;
|
||||
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Http\Resources\Json\JsonResource;
|
||||
|
||||
class InvoiceResource extends JsonResource
|
||||
{
|
||||
public function toArray(Request $request): array
|
||||
{
|
||||
return [
|
||||
...parent::toArray($request),
|
||||
'tenant' => $this->whenLoaded('tenant'),
|
||||
'customer' => $this->whenLoaded('customer'),
|
||||
'billing_profile' => $this->whenLoaded('billingProfile'),
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Resources\Customer;
|
||||
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Http\Resources\Json\JsonResource;
|
||||
|
||||
class CustomerResource extends JsonResource
|
||||
{
|
||||
public function toArray(Request $request): array
|
||||
{
|
||||
$data = parent::toArray($request);
|
||||
unset($data['images'], $data['cover_image']);
|
||||
|
||||
return [
|
||||
...$data,
|
||||
'tenant' => $this->whenLoaded('tenant'),
|
||||
'provinsi' => $this->whenLoaded('provinsi'),
|
||||
'kabupaten' => $this->whenLoaded('kabupaten'),
|
||||
'kecamatan' => $this->whenLoaded('kecamatan'),
|
||||
'desa' => $this->whenLoaded('desa'),
|
||||
'package_profile' => $this->whenLoaded('packageProfile'),
|
||||
'nas_mikrotik' => $this->whenLoaded('nasMikrotik'),
|
||||
'billing_profile' => $this->whenLoaded('billingProfile'),
|
||||
'images' => $this->whenLoaded('images', fn () => $this->images->map(fn ($image) => [
|
||||
'id' => $image->id,
|
||||
'stored_file_id' => $image->stored_file_id,
|
||||
'uuid' => $image->file?->uuid,
|
||||
'original_name' => $image->file?->original_name,
|
||||
'category' => $image->category,
|
||||
'caption' => $image->caption,
|
||||
'is_cover' => $image->is_cover,
|
||||
'sort_order' => $image->sort_order,
|
||||
])->values()),
|
||||
'cover_image' => $this->whenLoaded('coverImage', fn () => $this->coverImage ? [
|
||||
'stored_file_id' => $this->coverImage->stored_file_id,
|
||||
'uuid' => $this->coverImage->file?->uuid,
|
||||
'original_name' => $this->coverImage->file?->original_name,
|
||||
'size' => $this->coverImage->file?->size,
|
||||
] : null),
|
||||
'full_installation_address' => collect([
|
||||
$this->installation_address,
|
||||
$this->desa?->nama,
|
||||
$this->kecamatan?->nama,
|
||||
$this->kabupaten?->nama,
|
||||
$this->provinsi?->nama,
|
||||
])->filter()->implode(', '),
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Resources\Notification;
|
||||
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Http\Resources\Json\JsonResource;
|
||||
|
||||
class NotificationDeliveryResource extends JsonResource
|
||||
{
|
||||
public function toArray(Request $request): array
|
||||
{
|
||||
$destination = (string) $this->destination;
|
||||
$masked = strlen($destination) > 6
|
||||
? substr($destination, 0, 3).str_repeat('*', max(3, strlen($destination) - 6)).substr($destination, -3)
|
||||
: str_repeat('*', strlen($destination));
|
||||
|
||||
return [
|
||||
...parent::toArray($request),
|
||||
'destination' => $masked,
|
||||
'message' => $this->whenLoaded('message'),
|
||||
'channel_config' => $this->whenLoaded('channelConfig'),
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Resources\Payment;
|
||||
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Http\Resources\Json\JsonResource;
|
||||
|
||||
class PaymentTransactionResource extends JsonResource
|
||||
{
|
||||
public function toArray(Request $request): array
|
||||
{
|
||||
return [
|
||||
'id' => $this->id, 'uuid' => $this->uuid, 'transaction_number' => $this->transaction_number,
|
||||
'tenant' => $this->whenLoaded('tenant'), 'user' => $this->whenLoaded('user'),
|
||||
'customer' => $this->whenLoaded('customer'), 'invoice' => $this->whenLoaded('invoice'),
|
||||
'provider' => $this->account?->provider, 'gateway_scope' => $this->gateway_scope,
|
||||
'purpose' => $this->purpose, 'method' => $this->method, 'amount' => $this->amount,
|
||||
'provider_fee' => $this->provider_fee, 'platform_fee' => $this->platform_fee,
|
||||
'total_amount' => $this->total_amount, 'currency' => $this->currency, 'status' => $this->status,
|
||||
'provider_status' => $this->provider_status, 'provider_reference' => $this->provider_reference,
|
||||
'expires_at' => $this->expires_at, 'paid_at' => $this->paid_at, 'created_at' => $this->created_at,
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Resources\Topology;
|
||||
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Http\Resources\Json\JsonResource;
|
||||
|
||||
class TopologyResource extends JsonResource
|
||||
{
|
||||
public function toArray(Request $request): array
|
||||
{
|
||||
return [
|
||||
...parent::toArray($request),
|
||||
'tenant' => $this->whenLoaded('tenant'),
|
||||
'device_type' => $this->whenLoaded('deviceType'),
|
||||
'parent' => $this->whenLoaded('parent'),
|
||||
'ports' => $this->whenLoaded('ports'),
|
||||
'source_node' => $this->whenLoaded('sourceNode'),
|
||||
'target_node' => $this->whenLoaded('targetNode'),
|
||||
'source_port' => $this->whenLoaded('sourcePort'),
|
||||
'target_port' => $this->whenLoaded('targetPort'),
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
<?php
|
||||
|
||||
namespace App\Jobs;
|
||||
|
||||
use App\Models\NotificationBroadcast;
|
||||
use App\Services\Notification\NotificationService;
|
||||
use Illuminate\Contracts\Queue\ShouldQueue;
|
||||
use Illuminate\Foundation\Queue\Queueable;
|
||||
|
||||
class ProcessBroadcast implements ShouldQueue
|
||||
{
|
||||
use Queueable;
|
||||
|
||||
public int $tries = 3;
|
||||
|
||||
public function __construct(public int $broadcastId) {}
|
||||
|
||||
public function handle(NotificationService $service): void
|
||||
{
|
||||
$broadcast = NotificationBroadcast::with(['tenant', 'template', 'channelConfig'])->find($this->broadcastId);
|
||||
if ($broadcast) {
|
||||
$service->processBroadcast($broadcast);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
<?php
|
||||
|
||||
namespace App\Jobs;
|
||||
|
||||
use App\Models\NotificationDelivery;
|
||||
use App\Services\Notification\NotificationManager;
|
||||
use Illuminate\Contracts\Queue\ShouldQueue;
|
||||
use Illuminate\Foundation\Queue\Queueable;
|
||||
|
||||
class ProcessNotificationDelivery implements ShouldQueue
|
||||
{
|
||||
use Queueable;
|
||||
|
||||
public int $tries = 5;
|
||||
|
||||
public function __construct(public int $deliveryId) {}
|
||||
|
||||
public function backoff(): array
|
||||
{
|
||||
return [60, 300, 900, 3600];
|
||||
}
|
||||
|
||||
public function handle(NotificationManager $manager): void
|
||||
{
|
||||
$delivery = NotificationDelivery::find($this->deliveryId);
|
||||
if (! $delivery || in_array($delivery->status, ['sent', 'delivered', 'read', 'cancelled', 'skipped'], true)) {
|
||||
return;
|
||||
}
|
||||
$manager->deliver($delivery);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
|
||||
class BillingProfile extends Model
|
||||
{
|
||||
protected $guarded = ['id'];
|
||||
|
||||
protected function casts(): array
|
||||
{
|
||||
return ['tax_rate' => 'decimal:2'];
|
||||
}
|
||||
|
||||
public function tenant()
|
||||
{
|
||||
return $this->belongsTo(Tenant::class);
|
||||
}
|
||||
|
||||
public function customers()
|
||||
{
|
||||
return $this->hasMany(Customer::class);
|
||||
}
|
||||
|
||||
public function invoices()
|
||||
{
|
||||
return $this->hasMany(Invoice::class);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,98 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\SoftDeletes;
|
||||
|
||||
class Customer extends Model
|
||||
{
|
||||
use SoftDeletes;
|
||||
|
||||
protected $guarded = ['id'];
|
||||
|
||||
protected function casts(): array
|
||||
{
|
||||
return [
|
||||
'latitude' => 'decimal:7',
|
||||
'longitude' => 'decimal:7',
|
||||
'activated_at' => 'datetime',
|
||||
'inactivated_at' => 'datetime',
|
||||
];
|
||||
}
|
||||
|
||||
public function tenant()
|
||||
{
|
||||
return $this->belongsTo(Tenant::class);
|
||||
}
|
||||
|
||||
public function user()
|
||||
{
|
||||
return $this->belongsTo(User::class);
|
||||
}
|
||||
|
||||
public function provinsi()
|
||||
{
|
||||
return $this->belongsTo(WilayahProvinsi::class, 'provinsi_id');
|
||||
}
|
||||
|
||||
public function kabupaten()
|
||||
{
|
||||
return $this->belongsTo(WilayahKabupaten::class, 'kabupaten_id');
|
||||
}
|
||||
|
||||
public function kecamatan()
|
||||
{
|
||||
return $this->belongsTo(WilayahKecamatan::class, 'kecamatan_id');
|
||||
}
|
||||
|
||||
public function desa()
|
||||
{
|
||||
return $this->belongsTo(WilayahDesa::class, 'desa_id');
|
||||
}
|
||||
|
||||
public function packageProfile()
|
||||
{
|
||||
return $this->belongsTo(NasPackageProfile::class, 'package_profile_id');
|
||||
}
|
||||
|
||||
public function nasMikrotik()
|
||||
{
|
||||
return $this->belongsTo(NasMikrotik::class, 'nas_mikrotik_id');
|
||||
}
|
||||
|
||||
public function images()
|
||||
{
|
||||
return $this->hasMany(CustomerImage::class)->orderByDesc('is_cover')->orderBy('sort_order');
|
||||
}
|
||||
|
||||
public function coverImage()
|
||||
{
|
||||
return $this->hasOne(CustomerImage::class)->where('is_cover', true);
|
||||
}
|
||||
|
||||
public function billingProfile()
|
||||
{
|
||||
return $this->belongsTo(BillingProfile::class);
|
||||
}
|
||||
|
||||
public function invoices()
|
||||
{
|
||||
return $this->hasMany(Invoice::class);
|
||||
}
|
||||
|
||||
public function topologyConnections()
|
||||
{
|
||||
return $this->hasMany(TopologyCustomerConnection::class);
|
||||
}
|
||||
|
||||
public function activeTopologyConnection()
|
||||
{
|
||||
return $this->hasOne(TopologyCustomerConnection::class)->where('status', 'active');
|
||||
}
|
||||
|
||||
public function contacts()
|
||||
{
|
||||
return $this->hasMany(CustomerContact::class);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
|
||||
class CustomerContact extends Model
|
||||
{
|
||||
protected $guarded = ['id'];
|
||||
|
||||
protected function casts(): array
|
||||
{
|
||||
return [
|
||||
'is_primary' => 'boolean', 'is_verified' => 'boolean',
|
||||
'can_receive_transactional' => 'boolean', 'can_receive_broadcast' => 'boolean',
|
||||
'verified_at' => 'datetime', 'opted_out_at' => 'datetime',
|
||||
];
|
||||
}
|
||||
|
||||
public function customer() { return $this->belongsTo(Customer::class); }
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
|
||||
class CustomerImage extends Model
|
||||
{
|
||||
protected $guarded = ['id'];
|
||||
|
||||
protected function casts(): array
|
||||
{
|
||||
return ['is_cover' => 'boolean'];
|
||||
}
|
||||
|
||||
public function customer()
|
||||
{
|
||||
return $this->belongsTo(Customer::class);
|
||||
}
|
||||
|
||||
public function file()
|
||||
{
|
||||
return $this->belongsTo(StoredFile::class, 'stored_file_id');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\SoftDeletes;
|
||||
|
||||
class Invoice extends Model
|
||||
{
|
||||
use SoftDeletes;
|
||||
|
||||
protected $guarded = ['id'];
|
||||
|
||||
protected function casts(): array
|
||||
{
|
||||
return [
|
||||
'period_start' => 'date',
|
||||
'period_end' => 'date',
|
||||
'issue_date' => 'date',
|
||||
'send_date' => 'date',
|
||||
'warning_date' => 'date',
|
||||
'isolation_date' => 'date',
|
||||
'subtotal' => 'decimal:2',
|
||||
'tax_rate' => 'decimal:2',
|
||||
'tax_amount' => 'decimal:2',
|
||||
'total' => 'decimal:2',
|
||||
'paid_amount' => 'decimal:2',
|
||||
'balance' => 'decimal:2',
|
||||
'paid_at' => 'datetime',
|
||||
'profile_snapshot' => 'array',
|
||||
'metadata' => 'array',
|
||||
];
|
||||
}
|
||||
|
||||
public function tenant()
|
||||
{
|
||||
return $this->belongsTo(Tenant::class);
|
||||
}
|
||||
|
||||
public function customer()
|
||||
{
|
||||
return $this->belongsTo(Customer::class);
|
||||
}
|
||||
|
||||
public function billingProfile()
|
||||
{
|
||||
return $this->belongsTo(BillingProfile::class);
|
||||
}
|
||||
|
||||
public function creator()
|
||||
{
|
||||
return $this->belongsTo(User::class, 'created_by');
|
||||
}
|
||||
|
||||
public function updater()
|
||||
{
|
||||
return $this->belongsTo(User::class, 'updated_by');
|
||||
}
|
||||
|
||||
public function payer()
|
||||
{
|
||||
return $this->belongsTo(User::class, 'paid_by');
|
||||
}
|
||||
|
||||
public function paymentTransactions()
|
||||
{
|
||||
return $this->hasMany(PaymentTransaction::class);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
|
||||
class NotificationBroadcast extends Model
|
||||
{
|
||||
protected $guarded = ['id'];
|
||||
|
||||
protected function casts(): array
|
||||
{
|
||||
return ['filter_snapshot' => 'array', 'scheduled_at' => 'datetime'];
|
||||
}
|
||||
|
||||
public function tenant()
|
||||
{
|
||||
return $this->belongsTo(Tenant::class);
|
||||
}
|
||||
|
||||
public function template()
|
||||
{
|
||||
return $this->belongsTo(NotificationTemplate::class, 'notification_template_id');
|
||||
}
|
||||
|
||||
public function channelConfig()
|
||||
{
|
||||
return $this->belongsTo(NotificationChannel::class, 'notification_channel_id');
|
||||
}
|
||||
|
||||
public function recipients()
|
||||
{
|
||||
return $this->hasMany(NotificationBroadcastRecipient::class);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
|
||||
class NotificationBroadcastRecipient extends Model
|
||||
{
|
||||
protected $guarded = ['id'];
|
||||
|
||||
public function broadcast()
|
||||
{
|
||||
return $this->belongsTo(NotificationBroadcast::class, 'notification_broadcast_id');
|
||||
}
|
||||
|
||||
public function customer()
|
||||
{
|
||||
return $this->belongsTo(Customer::class);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
|
||||
class NotificationChannel extends Model
|
||||
{
|
||||
protected $guarded = ['id'];
|
||||
|
||||
protected $hidden = ['credentials'];
|
||||
|
||||
protected function casts(): array
|
||||
{
|
||||
return [
|
||||
'enabled' => 'boolean',
|
||||
'is_default' => 'boolean',
|
||||
'credentials' => 'encrypted:array',
|
||||
'settings' => 'array',
|
||||
'last_health_check_at' => 'datetime',
|
||||
];
|
||||
}
|
||||
|
||||
public function tenant()
|
||||
{
|
||||
return $this->belongsTo(Tenant::class);
|
||||
}
|
||||
|
||||
public function templates()
|
||||
{
|
||||
return $this->hasMany(NotificationTemplate::class);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
|
||||
class NotificationDelivery extends Model
|
||||
{
|
||||
protected $guarded = ['id'];
|
||||
|
||||
protected function casts(): array
|
||||
{
|
||||
return [
|
||||
'provider_response' => 'array', 'queued_at' => 'datetime', 'sent_at' => 'datetime',
|
||||
'delivered_at' => 'datetime', 'read_at' => 'datetime', 'failed_at' => 'datetime',
|
||||
'next_retry_at' => 'datetime',
|
||||
];
|
||||
}
|
||||
|
||||
public function message()
|
||||
{
|
||||
return $this->belongsTo(NotificationMessage::class, 'notification_message_id');
|
||||
}
|
||||
|
||||
public function channelConfig()
|
||||
{
|
||||
return $this->belongsTo(NotificationChannel::class, 'notification_channel_id');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
|
||||
class NotificationEvent extends Model
|
||||
{
|
||||
protected $guarded = ['id'];
|
||||
|
||||
protected function casts(): array
|
||||
{
|
||||
return ['available_channels' => 'array', 'variables' => 'array', 'is_system' => 'boolean', 'enabled' => 'boolean'];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
|
||||
class NotificationEventOccurrence extends Model
|
||||
{
|
||||
protected $guarded = ['id'];
|
||||
|
||||
protected function casts(): array
|
||||
{
|
||||
return ['processed_at' => 'datetime'];
|
||||
}
|
||||
|
||||
public function subjectable()
|
||||
{
|
||||
return $this->morphTo();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
|
||||
class NotificationMessage extends Model
|
||||
{
|
||||
protected $guarded = ['id'];
|
||||
|
||||
protected function casts(): array
|
||||
{
|
||||
return ['payload' => 'array', 'context' => 'array', 'scheduled_at' => 'datetime'];
|
||||
}
|
||||
|
||||
public function event()
|
||||
{
|
||||
return $this->belongsTo(NotificationEvent::class, 'notification_event_id');
|
||||
}
|
||||
|
||||
public function template()
|
||||
{
|
||||
return $this->belongsTo(NotificationTemplate::class, 'notification_template_id');
|
||||
}
|
||||
|
||||
public function deliveries()
|
||||
{
|
||||
return $this->hasMany(NotificationDelivery::class);
|
||||
}
|
||||
|
||||
public function broadcast()
|
||||
{
|
||||
return $this->belongsTo(NotificationBroadcast::class, 'notification_broadcast_id');
|
||||
}
|
||||
|
||||
public function recipient()
|
||||
{
|
||||
return $this->morphTo();
|
||||
}
|
||||
|
||||
public function subjectable()
|
||||
{
|
||||
return $this->morphTo();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
|
||||
class NotificationPreference extends Model
|
||||
{
|
||||
protected $guarded = ['id'];
|
||||
|
||||
protected function casts(): array
|
||||
{
|
||||
return ['enabled' => 'boolean'];
|
||||
}
|
||||
|
||||
public function event()
|
||||
{
|
||||
return $this->belongsTo(NotificationEvent::class, 'notification_event_id');
|
||||
}
|
||||
|
||||
public function channelConfig()
|
||||
{
|
||||
return $this->belongsTo(NotificationChannel::class, 'notification_channel_id');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
|
||||
class NotificationTemplate extends Model
|
||||
{
|
||||
protected $guarded = ['id'];
|
||||
|
||||
protected function casts(): array
|
||||
{
|
||||
return ['enabled' => 'boolean', 'variables' => 'array'];
|
||||
}
|
||||
|
||||
public function event()
|
||||
{
|
||||
return $this->belongsTo(NotificationEvent::class, 'notification_event_id');
|
||||
}
|
||||
|
||||
public function channelConfig()
|
||||
{
|
||||
return $this->belongsTo(NotificationChannel::class, 'notification_channel_id');
|
||||
}
|
||||
|
||||
public function tenant()
|
||||
{
|
||||
return $this->belongsTo(Tenant::class);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
|
||||
class NotificationWebhookLog extends Model
|
||||
{
|
||||
protected $guarded = ['id'];
|
||||
|
||||
protected function casts(): array
|
||||
{
|
||||
return ['signature_valid' => 'boolean', 'payload' => 'array', 'processed_at' => 'datetime'];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
|
||||
class PaymentAttempt extends Model
|
||||
{
|
||||
protected $guarded = ['id'];
|
||||
|
||||
protected function casts(): array
|
||||
{
|
||||
return ['request_payload' => 'array', 'response_payload' => 'array'];
|
||||
}
|
||||
|
||||
public function transaction()
|
||||
{
|
||||
return $this->belongsTo(PaymentTransaction::class, 'payment_transaction_id');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
|
||||
class PaymentGatewayAccount extends Model
|
||||
{
|
||||
protected $guarded = ['id'];
|
||||
|
||||
protected $hidden = ['credentials', 'webhook_secret'];
|
||||
|
||||
protected function casts(): array
|
||||
{
|
||||
return [
|
||||
'credentials' => 'encrypted:array',
|
||||
'webhook_secret' => 'encrypted',
|
||||
'settings' => 'array',
|
||||
'enabled' => 'boolean',
|
||||
'is_default' => 'boolean',
|
||||
'last_health_check_at' => 'datetime',
|
||||
];
|
||||
}
|
||||
|
||||
public function provider()
|
||||
{
|
||||
return $this->belongsTo(PaymentProvider::class, 'payment_provider_id');
|
||||
}
|
||||
|
||||
public function tenant()
|
||||
{
|
||||
return $this->belongsTo(Tenant::class);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
|
||||
class PaymentProvider extends Model
|
||||
{
|
||||
protected $guarded = ['id'];
|
||||
|
||||
protected function casts(): array
|
||||
{
|
||||
return ['capabilities' => 'array', 'credential_schema' => 'array', 'enabled' => 'boolean'];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
|
||||
class PaymentTransaction extends Model
|
||||
{
|
||||
protected $guarded = ['id'];
|
||||
|
||||
protected function casts(): array
|
||||
{
|
||||
return [
|
||||
'amount' => 'decimal:2', 'provider_fee' => 'decimal:2', 'platform_fee' => 'decimal:2',
|
||||
'total_amount' => 'decimal:2', 'expires_at' => 'datetime', 'paid_at' => 'datetime',
|
||||
'failed_at' => 'datetime', 'metadata' => 'array',
|
||||
];
|
||||
}
|
||||
|
||||
public function account()
|
||||
{
|
||||
return $this->belongsTo(PaymentGatewayAccount::class, 'payment_gateway_account_id');
|
||||
}
|
||||
|
||||
public function tenant()
|
||||
{
|
||||
return $this->belongsTo(Tenant::class);
|
||||
}
|
||||
|
||||
public function user()
|
||||
{
|
||||
return $this->belongsTo(User::class);
|
||||
}
|
||||
|
||||
public function customer()
|
||||
{
|
||||
return $this->belongsTo(Customer::class);
|
||||
}
|
||||
|
||||
public function invoice()
|
||||
{
|
||||
return $this->belongsTo(Invoice::class);
|
||||
}
|
||||
|
||||
public function attempts()
|
||||
{
|
||||
return $this->hasMany(PaymentAttempt::class);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
|
||||
class PaymentWebhookLog extends Model
|
||||
{
|
||||
protected $guarded = ['id'];
|
||||
|
||||
protected function casts(): array
|
||||
{
|
||||
return ['signature_valid' => 'boolean', 'headers' => 'array', 'payload' => 'array', 'processed_at' => 'datetime'];
|
||||
}
|
||||
}
|
||||
@@ -14,4 +14,14 @@ class Tenant extends Model
|
||||
'address',
|
||||
'status',
|
||||
];
|
||||
|
||||
public function wallets()
|
||||
{
|
||||
return $this->morphMany(Wallet::class, 'owner');
|
||||
}
|
||||
|
||||
public function paymentSetting()
|
||||
{
|
||||
return $this->hasOne(TenantPaymentSetting::class);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
|
||||
class TenantPaymentSetting extends Model
|
||||
{
|
||||
protected $guarded = ['id'];
|
||||
|
||||
protected function casts(): array
|
||||
{
|
||||
return ['enabled_methods' => 'array'];
|
||||
}
|
||||
|
||||
public function tenant()
|
||||
{
|
||||
return $this->belongsTo(Tenant::class);
|
||||
}
|
||||
|
||||
public function tenantGateway()
|
||||
{
|
||||
return $this->belongsTo(PaymentGatewayAccount::class, 'tenant_gateway_account_id');
|
||||
}
|
||||
|
||||
public function platformGateway()
|
||||
{
|
||||
return $this->belongsTo(PaymentGatewayAccount::class, 'platform_gateway_account_id');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
|
||||
class TopologyConnectionRule extends Model
|
||||
{
|
||||
protected $guarded = ['id'];
|
||||
|
||||
protected function casts(): array
|
||||
{
|
||||
return ['allowed' => 'boolean'];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
|
||||
class TopologyCustomerConnection extends Model
|
||||
{
|
||||
protected $guarded = ['id'];
|
||||
|
||||
protected function casts(): array
|
||||
{
|
||||
return [
|
||||
'connected_at' => 'datetime',
|
||||
'disconnected_at' => 'datetime',
|
||||
'metadata' => 'array',
|
||||
];
|
||||
}
|
||||
|
||||
public function customer()
|
||||
{
|
||||
return $this->belongsTo(Customer::class);
|
||||
}
|
||||
|
||||
public function odpNode()
|
||||
{
|
||||
return $this->belongsTo(TopologyNode::class, 'odp_node_id');
|
||||
}
|
||||
|
||||
public function odpPort()
|
||||
{
|
||||
return $this->belongsTo(TopologyPort::class, 'odp_port_id');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
|
||||
class TopologyDeviceType extends Model
|
||||
{
|
||||
protected $guarded = ['id'];
|
||||
|
||||
protected function casts(): array
|
||||
{
|
||||
return [
|
||||
'can_have_ports' => 'boolean',
|
||||
'can_have_splitters' => 'boolean',
|
||||
'is_system' => 'boolean',
|
||||
];
|
||||
}
|
||||
|
||||
public function nodes()
|
||||
{
|
||||
return $this->hasMany(TopologyNode::class, 'device_type_id');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\SoftDeletes;
|
||||
|
||||
class TopologyLink extends Model
|
||||
{
|
||||
use SoftDeletes;
|
||||
|
||||
protected $guarded = ['id'];
|
||||
|
||||
protected function casts(): array
|
||||
{
|
||||
return [
|
||||
'path_geojson' => 'array',
|
||||
'metadata' => 'array',
|
||||
'installed_at' => 'date',
|
||||
];
|
||||
}
|
||||
|
||||
public function tenant()
|
||||
{
|
||||
return $this->belongsTo(Tenant::class);
|
||||
}
|
||||
|
||||
public function sourceNode()
|
||||
{
|
||||
return $this->belongsTo(TopologyNode::class, 'source_node_id');
|
||||
}
|
||||
|
||||
public function targetNode()
|
||||
{
|
||||
return $this->belongsTo(TopologyNode::class, 'target_node_id');
|
||||
}
|
||||
|
||||
public function sourcePort()
|
||||
{
|
||||
return $this->belongsTo(TopologyPort::class, 'source_port_id');
|
||||
}
|
||||
|
||||
public function targetPort()
|
||||
{
|
||||
return $this->belongsTo(TopologyPort::class, 'target_port_id');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\SoftDeletes;
|
||||
|
||||
class TopologyNode extends Model
|
||||
{
|
||||
use SoftDeletes;
|
||||
|
||||
protected $guarded = ['id'];
|
||||
|
||||
protected function casts(): array
|
||||
{
|
||||
return [
|
||||
'latitude' => 'decimal:7',
|
||||
'longitude' => 'decimal:7',
|
||||
'installed_at' => 'date',
|
||||
'metadata' => 'array',
|
||||
];
|
||||
}
|
||||
|
||||
public function tenant()
|
||||
{
|
||||
return $this->belongsTo(Tenant::class);
|
||||
}
|
||||
|
||||
public function deviceType()
|
||||
{
|
||||
return $this->belongsTo(TopologyDeviceType::class, 'device_type_id');
|
||||
}
|
||||
|
||||
public function parent()
|
||||
{
|
||||
return $this->belongsTo(self::class, 'parent_node_id');
|
||||
}
|
||||
|
||||
public function ports()
|
||||
{
|
||||
return $this->hasMany(TopologyPort::class, 'node_id')->orderBy('port_number');
|
||||
}
|
||||
|
||||
public function outgoingLinks()
|
||||
{
|
||||
return $this->hasMany(TopologyLink::class, 'source_node_id');
|
||||
}
|
||||
|
||||
public function incomingLinks()
|
||||
{
|
||||
return $this->hasMany(TopologyLink::class, 'target_node_id');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
|
||||
class TopologyPort extends Model
|
||||
{
|
||||
protected $guarded = ['id'];
|
||||
|
||||
protected function casts(): array
|
||||
{
|
||||
return ['metadata' => 'array'];
|
||||
}
|
||||
|
||||
public function node()
|
||||
{
|
||||
return $this->belongsTo(TopologyNode::class, 'node_id');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
|
||||
class TopologySplitter extends Model
|
||||
{
|
||||
protected $guarded = ['id'];
|
||||
|
||||
protected function casts(): array
|
||||
{
|
||||
return ['metadata' => 'array'];
|
||||
}
|
||||
}
|
||||
@@ -102,6 +102,16 @@ class User extends Authenticatable
|
||||
return $this->hasMany(UserMenuGroup::class, 'user_id');
|
||||
}
|
||||
|
||||
public function userNotifications()
|
||||
{
|
||||
return $this->hasMany(UserNotification::class, 'user_id');
|
||||
}
|
||||
|
||||
public function wallets()
|
||||
{
|
||||
return $this->morphMany(Wallet::class, 'owner');
|
||||
}
|
||||
|
||||
public function isMasterAdmin(): bool
|
||||
{
|
||||
return $this->is_master || $this->access_level === UserAccessLevel::MASTER_ADMIN;
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
|
||||
class UserNotification extends Model
|
||||
{
|
||||
protected $guarded = ['id'];
|
||||
|
||||
protected function casts(): array
|
||||
{
|
||||
return ['read_at' => 'datetime'];
|
||||
}
|
||||
|
||||
public function user() { return $this->belongsTo(User::class); }
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
|
||||
class Wallet extends Model
|
||||
{
|
||||
protected $guarded = ['id'];
|
||||
|
||||
protected function casts(): array
|
||||
{
|
||||
return ['available_balance' => 'decimal:2', 'pending_balance' => 'decimal:2', 'locked_balance' => 'decimal:2'];
|
||||
}
|
||||
|
||||
public function owner()
|
||||
{
|
||||
return $this->morphTo();
|
||||
}
|
||||
|
||||
public function ledgerEntries()
|
||||
{
|
||||
return $this->hasMany(WalletLedgerEntry::class);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
|
||||
class WalletLedgerEntry extends Model
|
||||
{
|
||||
protected $guarded = ['id'];
|
||||
|
||||
protected function casts(): array
|
||||
{
|
||||
return ['amount' => 'decimal:2', 'balance_before' => 'decimal:2', 'balance_after' => 'decimal:2'];
|
||||
}
|
||||
|
||||
public function wallet()
|
||||
{
|
||||
return $this->belongsTo(Wallet::class);
|
||||
}
|
||||
|
||||
public function transaction()
|
||||
{
|
||||
return $this->belongsTo(WalletTransaction::class, 'wallet_transaction_id');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
|
||||
class WalletTransaction extends Model
|
||||
{
|
||||
protected $guarded = ['id'];
|
||||
|
||||
protected function casts(): array
|
||||
{
|
||||
return ['amount' => 'decimal:2', 'fee' => 'decimal:2', 'metadata' => 'array', 'completed_at' => 'datetime'];
|
||||
}
|
||||
|
||||
public function entries()
|
||||
{
|
||||
return $this->hasMany(WalletLedgerEntry::class);
|
||||
}
|
||||
|
||||
public function payment()
|
||||
{
|
||||
return $this->belongsTo(PaymentTransaction::class, 'payment_transaction_id');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
|
||||
class WalletWithdrawal extends Model
|
||||
{
|
||||
protected $guarded = ['id'];
|
||||
|
||||
protected function casts(): array
|
||||
{
|
||||
return ['amount' => 'decimal:2', 'fee' => 'decimal:2', 'processed_at' => 'datetime'];
|
||||
}
|
||||
|
||||
public function wallet()
|
||||
{
|
||||
return $this->belongsTo(Wallet::class);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services\Billing;
|
||||
|
||||
use App\Models\BillingProfile;
|
||||
use App\Models\User;
|
||||
use Illuminate\Validation\ValidationException;
|
||||
|
||||
class BillingProfileService
|
||||
{
|
||||
public function list(array $filters, User $actor, ?int $tenantId)
|
||||
{
|
||||
return BillingProfile::query()->with('tenant:id,tenant_code,tenant_name')
|
||||
->when(! $actor->isMasterAdmin(), fn ($q) => $q->where('tenant_id', $tenantId))
|
||||
->when($actor->isMasterAdmin() && $tenantId, fn ($q) => $q->where('tenant_id', $tenantId))
|
||||
->when(! empty($filters['status']), fn ($q) => $q->where('status', $filters['status']))
|
||||
->when(! empty($filters['search']), fn ($q) => $q->where('name', 'ILIKE', "%{$filters['search']}%"))
|
||||
->orderBy($filters['sort_by'] ?? 'id', $filters['sort_direction'] ?? 'desc')
|
||||
->paginate($filters['per_page'] ?? 10);
|
||||
}
|
||||
|
||||
public function find(int $id): BillingProfile
|
||||
{
|
||||
return BillingProfile::with('tenant:id,tenant_code,tenant_name')->findOrFail($id);
|
||||
}
|
||||
|
||||
public function create(array $data, User $actor, ?int $tenantId): BillingProfile
|
||||
{
|
||||
$data['tenant_id'] = $actor->isMasterAdmin() ? ($data['tenant_id'] ?? $tenantId) : $tenantId;
|
||||
if (! $data['tenant_id']) {
|
||||
throw ValidationException::withMessages(['tenant_id' => 'Tenant wajib dipilih.']);
|
||||
}
|
||||
$data['tax_rate'] = $data['tax_type'] === 'non_tax' ? 0 : ($data['tax_rate'] ?? 11);
|
||||
$data['status'] ??= 'active';
|
||||
$data = $this->normalizeSchedule($data);
|
||||
|
||||
return BillingProfile::create($data)->load('tenant:id,tenant_code,tenant_name');
|
||||
}
|
||||
|
||||
public function update(BillingProfile $profile, array $data, User $actor): BillingProfile
|
||||
{
|
||||
if (! $actor->isMasterAdmin()) {
|
||||
unset($data['tenant_id']);
|
||||
}
|
||||
if (($data['tax_type'] ?? $profile->tax_type) === 'non_tax') {
|
||||
$data['tax_rate'] = 0;
|
||||
}
|
||||
$data = $this->normalizeSchedule($data, $profile);
|
||||
$profile->update($data);
|
||||
|
||||
return $profile->fresh()->load('tenant:id,tenant_code,tenant_name');
|
||||
}
|
||||
|
||||
public function delete(BillingProfile $profile): void
|
||||
{
|
||||
if ($profile->customers()->exists() || $profile->invoices()->exists()) {
|
||||
throw ValidationException::withMessages(['profile' => 'Profile tagihan masih digunakan customer atau invoice.']);
|
||||
}
|
||||
$profile->delete();
|
||||
}
|
||||
|
||||
private function normalizeSchedule(array $data, ?BillingProfile $profile = null): array
|
||||
{
|
||||
$type = $data['schedule_type'] ?? $profile?->schedule_type ?? 'fixed_date';
|
||||
$data['schedule_type'] = $type;
|
||||
|
||||
if ($type === 'installation_date') {
|
||||
$data['invoice_day'] = $profile?->invoice_day ?? 1;
|
||||
$data['send_day'] = $profile?->send_day ?? 1;
|
||||
$data['warning_day'] = $profile?->warning_day ?? 1;
|
||||
$data['isolation_day'] = $profile?->isolation_day ?? 1;
|
||||
} else {
|
||||
$data['invoice_days_before'] = null;
|
||||
$data['send_days_before'] = null;
|
||||
$data['warning_days_before'] = null;
|
||||
$data['isolation_days_after'] = null;
|
||||
}
|
||||
|
||||
return $data;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,346 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services\Billing;
|
||||
|
||||
use App\Models\BillingProfile;
|
||||
use App\Models\Customer;
|
||||
use App\Models\Invoice;
|
||||
use App\Models\User;
|
||||
use App\Services\Notification\NotificationManager;
|
||||
use Carbon\Carbon;
|
||||
use Illuminate\Database\QueryException;
|
||||
use Illuminate\Validation\ValidationException;
|
||||
|
||||
class InvoiceService
|
||||
{
|
||||
public function __construct(protected NotificationManager $notifications) {}
|
||||
|
||||
private const RELATIONS = [
|
||||
'tenant:id,tenant_code,tenant_name',
|
||||
'customer:id,tenant_id,customer_code,name,whatsapp_number,email,status',
|
||||
'billingProfile:id,tenant_id,name,schedule_type,invoice_day,send_day,warning_day,isolation_day,invoice_days_before,send_days_before,warning_days_before,isolation_days_after,tax_type,tax_rate',
|
||||
];
|
||||
|
||||
public function list(string $scope, array $filters, User $actor, ?int $tenantId)
|
||||
{
|
||||
$query = Invoice::query()->with(self::RELATIONS);
|
||||
if ($scope === 'running') {
|
||||
$query->whereIn('status', ['draft', 'published', 'sent', 'warning']);
|
||||
} elseif ($scope === 'overdue') {
|
||||
$query->where('status', 'overdue');
|
||||
} elseif ($scope === 'paid') {
|
||||
$query->where('status', 'paid');
|
||||
}
|
||||
|
||||
return $query
|
||||
->when(! $actor->isMasterAdmin(), fn ($q) => $q->where('tenant_id', $tenantId))
|
||||
->when($actor->isMasterAdmin() && $tenantId, fn ($q) => $q->where('tenant_id', $tenantId))
|
||||
->when(! empty($filters['status']), fn ($q) => $q->where('status', $filters['status']))
|
||||
->when(! empty($filters['date_from']), fn ($q) => $q->whereDate('issue_date', '>=', $filters['date_from']))
|
||||
->when(! empty($filters['date_to']), fn ($q) => $q->whereDate('issue_date', '<=', $filters['date_to']))
|
||||
->when(! empty($filters['search']), fn ($q) => $q->where(function ($search) use ($filters) {
|
||||
$term = "%{$filters['search']}%";
|
||||
$search->where('invoice_number', 'ILIKE', $term)
|
||||
->orWhere('customer_name_snapshot', 'ILIKE', $term)
|
||||
->orWhere('customer_code_snapshot', 'ILIKE', $term);
|
||||
}))
|
||||
->orderBy($filters['sort_by'] ?? 'id', $filters['sort_direction'] ?? 'desc')
|
||||
->paginate($filters['per_page'] ?? 10);
|
||||
}
|
||||
|
||||
public function find(int $id): Invoice
|
||||
{
|
||||
return Invoice::with(self::RELATIONS)->findOrFail($id);
|
||||
}
|
||||
|
||||
public function options(User $actor, ?int $tenantId): array
|
||||
{
|
||||
$tenantScope = fn ($q) => $q
|
||||
->when(! $actor->isMasterAdmin(), fn ($x) => $x->where('tenant_id', $tenantId))
|
||||
->when($actor->isMasterAdmin() && $tenantId, fn ($x) => $x->where('tenant_id', $tenantId));
|
||||
|
||||
return [
|
||||
'customers' => $tenantScope(Customer::query())
|
||||
->with('billingProfile')
|
||||
->whereIn('status', ['active', 'order'])
|
||||
->orderBy('name')
|
||||
->get(['id', 'tenant_id', 'customer_code', 'name', 'billing_profile_id', 'package_profile_id', 'activated_at']),
|
||||
];
|
||||
}
|
||||
|
||||
public function create(array $data, ?User $actor, ?int $tenantId): Invoice
|
||||
{
|
||||
$data['tenant_id'] = $actor?->isMasterAdmin() ? ($data['tenant_id'] ?? $tenantId) : $tenantId;
|
||||
if (! $data['tenant_id']) {
|
||||
throw ValidationException::withMessages(['tenant_id' => 'Tenant wajib dipilih.']);
|
||||
}
|
||||
$customer = Customer::with('packageProfile')->whereKey($data['customer_id'])->where('tenant_id', $data['tenant_id'])->first();
|
||||
if (! $customer) {
|
||||
throw ValidationException::withMessages(['customer_id' => 'Customer bukan milik tenant aktif.']);
|
||||
}
|
||||
$profile = BillingProfile::whereKey($customer->billing_profile_id)
|
||||
->where('tenant_id', $data['tenant_id'])
|
||||
->where('status', 'active')
|
||||
->first();
|
||||
if (! $profile) {
|
||||
throw ValidationException::withMessages([
|
||||
'customer_id' => 'Customer belum memiliki profile tagihan aktif.',
|
||||
]);
|
||||
}
|
||||
$data = [...$data, ...$this->scheduleDates($profile, $customer, $data)];
|
||||
$amounts = $this->amounts((float) $data['subtotal'], $profile->tax_type, (float) $profile->tax_rate);
|
||||
$data = [
|
||||
...$data,
|
||||
...$amounts,
|
||||
'billing_profile_id' => $profile->id,
|
||||
'invoice_number' => filled($data['invoice_number'] ?? null) ? $data['invoice_number'] : $this->nextNumber((int) $data['tenant_id']),
|
||||
'status' => $data['status'] ?? $this->statusByDate($data),
|
||||
'payment_status' => 'unpaid',
|
||||
'created_by' => $actor?->id,
|
||||
'updated_by' => $actor?->id,
|
||||
'customer_code_snapshot' => $customer->customer_code,
|
||||
'customer_name_snapshot' => $customer->name,
|
||||
'customer_address_snapshot' => $customer->full_installation_address ?? $customer->installation_address,
|
||||
'package_name_snapshot' => $customer->packageProfile?->name,
|
||||
'profile_snapshot' => $profile->only([
|
||||
'name', 'schedule_type', 'invoice_day', 'send_day', 'warning_day', 'isolation_day',
|
||||
'invoice_days_before', 'send_days_before', 'warning_days_before',
|
||||
'isolation_days_after', 'tax_type', 'tax_rate',
|
||||
]),
|
||||
];
|
||||
|
||||
$invoice = Invoice::create($data)->load(self::RELATIONS);
|
||||
$this->emitDueNotifications($invoice);
|
||||
|
||||
return $invoice;
|
||||
}
|
||||
|
||||
public function processSchedules(): array
|
||||
{
|
||||
$created = 0;
|
||||
$today = now()->startOfDay();
|
||||
$periods = [
|
||||
$today->copy()->startOfMonth(),
|
||||
$today->copy()->addMonthNoOverflow()->startOfMonth(),
|
||||
];
|
||||
|
||||
Customer::query()
|
||||
->with(['billingProfile', 'packageProfile'])
|
||||
->where('status', 'active')
|
||||
->whereNotNull('billing_profile_id')
|
||||
->whereNotNull('package_profile_id')
|
||||
->chunkById(200, function ($customers) use (&$created, $periods, $today) {
|
||||
foreach ($customers as $customer) {
|
||||
if (! $customer->billingProfile || $customer->billingProfile->status !== 'active') {
|
||||
continue;
|
||||
}
|
||||
if ($customer->billingProfile->schedule_type === 'installation_date' && ! $customer->activated_at) {
|
||||
continue;
|
||||
}
|
||||
foreach ($periods as $period) {
|
||||
$schedule = $this->scheduleDates(
|
||||
$customer->billingProfile,
|
||||
$customer,
|
||||
['period_start' => $period->toDateString()],
|
||||
);
|
||||
if (Carbon::parse($schedule['issue_date'])->startOfDay()->gt($today)) {
|
||||
continue;
|
||||
}
|
||||
$periodEnd = $period->copy()->endOfMonth()->toDateString();
|
||||
if (Invoice::withTrashed()
|
||||
->where('customer_id', $customer->id)
|
||||
->whereDate('period_start', $period->toDateString())
|
||||
->whereDate('period_end', $periodEnd)
|
||||
->exists()) {
|
||||
continue;
|
||||
}
|
||||
|
||||
try {
|
||||
$this->create([
|
||||
'tenant_id' => $customer->tenant_id,
|
||||
'customer_id' => $customer->id,
|
||||
'period_start' => $period->toDateString(),
|
||||
'period_end' => $periodEnd,
|
||||
'subtotal' => (float) $customer->packageProfile->price,
|
||||
...$schedule,
|
||||
], null, $customer->tenant_id);
|
||||
$created++;
|
||||
} catch (QueryException $exception) {
|
||||
if (! $this->isDuplicateKey($exception)) {
|
||||
throw $exception;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
return ['created' => $created, 'updated' => $this->refreshStatuses()];
|
||||
}
|
||||
|
||||
public function update(Invoice $invoice, array $data, User $actor): Invoice
|
||||
{
|
||||
unset($data['tenant_id'], $data['customer_id'], $data['billing_profile_id'], $data['invoice_number']);
|
||||
if (array_key_exists('subtotal', $data)) {
|
||||
$data = [...$data, ...$this->amounts((float) $data['subtotal'], $invoice->tax_type, (float) $invoice->tax_rate, (float) $invoice->paid_amount)];
|
||||
}
|
||||
$data['updated_by'] = $actor->id;
|
||||
$invoice->update($data);
|
||||
|
||||
return $invoice->fresh()->load(self::RELATIONS);
|
||||
}
|
||||
|
||||
public function pay(Invoice $invoice, array $data, User $actor): Invoice
|
||||
{
|
||||
if ($invoice->status === 'cancelled') {
|
||||
throw ValidationException::withMessages(['invoice' => 'Invoice dibatalkan tidak dapat dibayar.']);
|
||||
}
|
||||
$paidAmount = min((float) $invoice->total, (float) $invoice->paid_amount + (float) $data['amount']);
|
||||
$balance = max(0, (float) $invoice->total - $paidAmount);
|
||||
$invoice->update([
|
||||
'paid_amount' => $paidAmount,
|
||||
'balance' => $balance,
|
||||
'payment_status' => $balance <= 0 ? 'paid' : 'partial',
|
||||
'status' => $balance <= 0 ? 'paid' : $this->statusByDate($invoice->toArray()),
|
||||
'paid_at' => $balance <= 0 ? ($data['paid_at'] ?? now()) : null,
|
||||
'payment_method' => $data['payment_method'] ?? $invoice->payment_method,
|
||||
'payment_reference' => $data['payment_reference'] ?? $invoice->payment_reference,
|
||||
'paid_by' => $actor->id,
|
||||
'updated_by' => $actor->id,
|
||||
'notes' => $data['notes'] ?? $invoice->notes,
|
||||
]);
|
||||
|
||||
return $invoice->fresh()->load(self::RELATIONS);
|
||||
}
|
||||
|
||||
public function cancel(Invoice $invoice, User $actor): Invoice
|
||||
{
|
||||
if ((float) $invoice->paid_amount > 0) {
|
||||
throw ValidationException::withMessages(['invoice' => 'Invoice yang sudah memiliki pembayaran tidak dapat dibatalkan.']);
|
||||
}
|
||||
$invoice->update(['status' => 'cancelled', 'updated_by' => $actor->id]);
|
||||
|
||||
return $invoice->fresh()->load(self::RELATIONS);
|
||||
}
|
||||
|
||||
private function amounts(float $subtotal, string $type, float $rate, float $paid = 0): array
|
||||
{
|
||||
$tax = match ($type) {
|
||||
'inclusive' => $rate > 0 ? round($subtotal * $rate / (100 + $rate), 2) : 0,
|
||||
'exclusive' => round($subtotal * $rate / 100, 2),
|
||||
default => 0,
|
||||
};
|
||||
$total = $type === 'exclusive' ? $subtotal + $tax : $subtotal;
|
||||
|
||||
return ['tax_type' => $type, 'tax_rate' => $rate, 'tax_amount' => $tax, 'total' => $total, 'balance' => max(0, $total - $paid)];
|
||||
}
|
||||
|
||||
private function scheduleDates(BillingProfile $profile, Customer $customer, array $data): array
|
||||
{
|
||||
$periodDate = Carbon::parse($data['period_start'])->startOfMonth();
|
||||
if ($profile->schedule_type !== 'installation_date') {
|
||||
return [
|
||||
'issue_date' => $periodDate->copy()->day((int) $profile->invoice_day)->toDateString(),
|
||||
'send_date' => $periodDate->copy()->day((int) $profile->send_day)->toDateString(),
|
||||
'warning_date' => $periodDate->copy()->day((int) $profile->warning_day)->toDateString(),
|
||||
'isolation_date' => $periodDate->copy()->day((int) $profile->isolation_day)->toDateString(),
|
||||
];
|
||||
}
|
||||
if (! $customer->activated_at) {
|
||||
throw ValidationException::withMessages([
|
||||
'customer_id' => 'Tanggal pemasangan customer belum tersedia untuk profile tagihan ini.',
|
||||
]);
|
||||
}
|
||||
|
||||
$installationDay = $customer->activated_at->day;
|
||||
$anchor = $periodDate->copy()->day(min($installationDay, $periodDate->daysInMonth));
|
||||
|
||||
return [
|
||||
'issue_date' => $anchor->copy()->subDays((int) $profile->invoice_days_before)->toDateString(),
|
||||
'send_date' => $anchor->copy()->subDays((int) $profile->send_days_before)->toDateString(),
|
||||
'warning_date' => $anchor->copy()->subDays((int) $profile->warning_days_before)->toDateString(),
|
||||
'isolation_date' => $anchor->copy()->addDays((int) $profile->isolation_days_after)->toDateString(),
|
||||
];
|
||||
}
|
||||
|
||||
private function statusByDate(array $data): string
|
||||
{
|
||||
$today = now()->startOfDay();
|
||||
if (! empty($data['isolation_date']) && Carbon::parse($data['isolation_date'])->startOfDay()->lte($today)) {
|
||||
return 'overdue';
|
||||
}
|
||||
if (! empty($data['warning_date']) && Carbon::parse($data['warning_date'])->startOfDay()->lte($today)) {
|
||||
return 'warning';
|
||||
}
|
||||
if (! empty($data['send_date']) && Carbon::parse($data['send_date'])->startOfDay()->lte($today)) {
|
||||
return 'sent';
|
||||
}
|
||||
if (! empty($data['issue_date']) && Carbon::parse($data['issue_date'])->startOfDay()->lte($today)) {
|
||||
return 'published';
|
||||
}
|
||||
|
||||
return 'draft';
|
||||
}
|
||||
|
||||
private function refreshStatuses(?User $actor = null, ?int $tenantId = null): int
|
||||
{
|
||||
$updated = 0;
|
||||
Invoice::query()->whereNotIn('status', ['paid', 'cancelled'])
|
||||
->when($actor && (! $actor->isMasterAdmin() || $tenantId), fn ($q) => $q->where('tenant_id', $tenantId))
|
||||
->chunkById(500, function ($invoices) use (&$updated) {
|
||||
$invoices->each(function (Invoice $invoice) use (&$updated) {
|
||||
$status = $this->statusByDate($invoice->toArray());
|
||||
if ($status !== $invoice->status) {
|
||||
$invoice->updateQuietly(['status' => $status]);
|
||||
$updated++;
|
||||
}
|
||||
$this->emitDueNotifications($invoice->fresh(self::RELATIONS));
|
||||
});
|
||||
});
|
||||
|
||||
return $updated;
|
||||
}
|
||||
|
||||
private function isDuplicateKey(QueryException $exception): bool
|
||||
{
|
||||
return in_array((string) $exception->getCode(), ['23000', '23505'], true);
|
||||
}
|
||||
|
||||
private function emitDueNotifications(Invoice $invoice): void
|
||||
{
|
||||
if (in_array($invoice->status, ['paid', 'cancelled'], true) || ! $invoice->customer) {
|
||||
return;
|
||||
}
|
||||
$payload = [
|
||||
'tenant' => $invoice->tenant?->toArray() ?? [],
|
||||
'customer' => $invoice->customer->toArray(),
|
||||
'invoice' => [
|
||||
...$invoice->toArray(),
|
||||
'total' => 'Rp '.number_format((float) $invoice->total, 0, ',', '.'),
|
||||
'balance' => 'Rp '.number_format((float) $invoice->balance, 0, ',', '.'),
|
||||
],
|
||||
];
|
||||
foreach ([
|
||||
'billing.invoice_sent' => $invoice->send_date,
|
||||
'billing.warning' => $invoice->warning_date,
|
||||
'billing.isolated' => $invoice->isolation_date,
|
||||
] as $event => $date) {
|
||||
if ($date && $date->copy()->startOfDay()->lte(now()->startOfDay())) {
|
||||
$this->notifications->emit(
|
||||
$event,
|
||||
$invoice->customer,
|
||||
$invoice,
|
||||
$payload,
|
||||
$event.'-'.$invoice->period_start->format('Ym'),
|
||||
['action_url' => "/billing/running?invoice={$invoice->id}"],
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private function nextNumber(int $tenantId): string
|
||||
{
|
||||
$sequence = Invoice::withTrashed()->where('tenant_id', $tenantId)->whereYear('created_at', now()->year)->count() + 1;
|
||||
|
||||
return 'INV-'.now()->format('Ym').'-'.str_pad((string) $sequence, 5, '0', STR_PAD_LEFT);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,283 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services\Customer;
|
||||
|
||||
use App\Models\BillingProfile;
|
||||
use App\Models\Customer;
|
||||
use App\Models\CustomerContact;
|
||||
use App\Models\NasMikrotik;
|
||||
use App\Models\NasPackageProfile;
|
||||
use App\Models\StoredFile;
|
||||
use App\Models\User;
|
||||
use App\Models\WilayahDesa;
|
||||
use App\Services\Notification\NotificationManager;
|
||||
use Illuminate\Validation\ValidationException;
|
||||
|
||||
class CustomerService
|
||||
{
|
||||
public function __construct(protected NotificationManager $notifications) {}
|
||||
|
||||
private const BASE_RELATIONS = [
|
||||
'tenant:id,tenant_code,tenant_name',
|
||||
'provinsi:id,kode,nama',
|
||||
'kabupaten:id,provinsi_id,kode,nama',
|
||||
'kecamatan:id,kabupaten_id,kode,nama',
|
||||
'desa:id,kecamatan_id,kode,nama',
|
||||
'packageProfile:id,tenant_id,name,service_type,download_kbps,upload_kbps,price',
|
||||
'nasMikrotik:id,tenant_id,name,connection_type,host,status',
|
||||
'billingProfile:id,tenant_id,name,schedule_type,invoice_day,send_day,warning_day,isolation_day,invoice_days_before,send_days_before,warning_days_before,isolation_days_after,tax_type,tax_rate,status',
|
||||
];
|
||||
|
||||
private const DETAIL_RELATIONS = [
|
||||
...self::BASE_RELATIONS,
|
||||
'images.file:id,uuid,tenant_id,uploaded_by,original_name,mime_type,size,category',
|
||||
];
|
||||
|
||||
private const LIST_RELATIONS = [
|
||||
...self::BASE_RELATIONS,
|
||||
'coverImage.file:id,uuid,original_name,mime_type,size',
|
||||
];
|
||||
|
||||
public function list(string $scope, array $filters, User $actor, ?int $tenantId)
|
||||
{
|
||||
$query = Customer::query()->with(self::LIST_RELATIONS);
|
||||
if ($scope === 'trash') {
|
||||
$query->onlyTrashed();
|
||||
} else {
|
||||
$query->where('status', $scope === 'orders' ? 'order' : $scope);
|
||||
}
|
||||
|
||||
return $query
|
||||
->when(! $actor->isMasterAdmin(), fn ($q) => $q->where('tenant_id', $tenantId))
|
||||
->when($actor->isMasterAdmin() && $tenantId, fn ($q) => $q->where('tenant_id', $tenantId))
|
||||
->when(! empty($filters['source']), fn ($q) => $q->where('source', $filters['source']))
|
||||
->when(! empty($filters['search']), fn ($q) => $q->where(function ($search) use ($filters) {
|
||||
$term = "%{$filters['search']}%";
|
||||
$search->where('customer_code', 'ILIKE', $term)
|
||||
->orWhere('name', 'ILIKE', $term)
|
||||
->orWhere('email', 'ILIKE', $term)
|
||||
->orWhere('whatsapp_number', 'ILIKE', $term)
|
||||
->orWhere('external_username', 'ILIKE', $term);
|
||||
}))
|
||||
->orderBy($filters['sort_by'] ?? 'id', $filters['sort_direction'] ?? 'desc')
|
||||
->paginate($filters['per_page'] ?? 10);
|
||||
}
|
||||
|
||||
public function find(int $id, bool $trashed = false): Customer
|
||||
{
|
||||
return Customer::query()
|
||||
->when($trashed, fn ($q) => $q->withTrashed())
|
||||
->with(self::DETAIL_RELATIONS)
|
||||
->findOrFail($id);
|
||||
}
|
||||
|
||||
public function create(array $data, User $actor, ?int $tenantId): Customer
|
||||
{
|
||||
$images = $data['images'] ?? [];
|
||||
unset($data['images']);
|
||||
$data['tenant_id'] = $actor->isMasterAdmin() ? ($data['tenant_id'] ?? $tenantId) : $tenantId;
|
||||
if (! $data['tenant_id']) {
|
||||
throw ValidationException::withMessages(['tenant_id' => 'Tenant wajib dipilih.']);
|
||||
}
|
||||
$data['customer_code'] = filled($data['customer_code'] ?? null)
|
||||
? $data['customer_code']
|
||||
: $this->nextCode((int) $data['tenant_id']);
|
||||
$data['source'] ??= 'manual';
|
||||
$data['status'] = $data['source'] === 'manual' ? 'order' : 'unmanaged';
|
||||
$data['order_stage'] ??= 'registration';
|
||||
$this->validateTenantRelations($data);
|
||||
$this->validateWilayah($data);
|
||||
|
||||
$customer = Customer::create($data);
|
||||
$this->syncImages($customer, $images, $actor);
|
||||
$this->syncPrimaryContacts($customer);
|
||||
$customer->load('tenant:id,tenant_name');
|
||||
$this->notifications->emit(
|
||||
'customer.registration',
|
||||
$customer,
|
||||
$customer,
|
||||
['customer' => $customer->toArray(), 'tenant' => $customer->tenant?->toArray() ?? []],
|
||||
'registration',
|
||||
['action_url' => "/customers/orders/{$customer->id}/detail"],
|
||||
);
|
||||
|
||||
return $customer->load(self::DETAIL_RELATIONS);
|
||||
}
|
||||
|
||||
public function update(Customer $customer, array $data, User $actor): Customer
|
||||
{
|
||||
$hasImages = array_key_exists('images', $data);
|
||||
$images = $data['images'] ?? [];
|
||||
unset($data['images']);
|
||||
if (! $actor->isMasterAdmin()) {
|
||||
unset($data['tenant_id']);
|
||||
}
|
||||
$data['tenant_id'] ??= $customer->tenant_id;
|
||||
$this->validateTenantRelations($data);
|
||||
$this->validateWilayah($data);
|
||||
unset($data['status']);
|
||||
$customer->update($data);
|
||||
$this->syncPrimaryContacts($customer);
|
||||
if ($hasImages) {
|
||||
$this->syncImages($customer, $images, $actor);
|
||||
}
|
||||
|
||||
return $customer->fresh()->load(self::DETAIL_RELATIONS);
|
||||
}
|
||||
|
||||
public function activate(Customer $customer, array $data): Customer
|
||||
{
|
||||
$data['tenant_id'] = $customer->tenant_id;
|
||||
$this->validateTenantRelations($data);
|
||||
if (! $customer->desa_id || ! $customer->installation_address) {
|
||||
throw ValidationException::withMessages([
|
||||
'installation_address' => 'Alamat pemasangan dan wilayah desa wajib lengkap sebelum aktivasi.',
|
||||
]);
|
||||
}
|
||||
$customer->update([
|
||||
...$data,
|
||||
'status' => 'active',
|
||||
'order_stage' => 'ready_activation',
|
||||
'activated_at' => now(),
|
||||
'inactivated_at' => null,
|
||||
]);
|
||||
$customer->load('tenant:id,tenant_name');
|
||||
$this->notifications->emit(
|
||||
'customer.activation',
|
||||
$customer,
|
||||
$customer,
|
||||
['customer' => $customer->toArray(), 'tenant' => $customer->tenant?->toArray() ?? []],
|
||||
'activation-'.$customer->activated_at->format('YmdHis'),
|
||||
['action_url' => "/customers/active/{$customer->id}/detail"],
|
||||
);
|
||||
|
||||
return $customer->fresh()->load(self::DETAIL_RELATIONS);
|
||||
}
|
||||
|
||||
public function deactivate(Customer $customer): Customer
|
||||
{
|
||||
$customer->update(['status' => 'inactive', 'inactivated_at' => now()]);
|
||||
|
||||
return $customer->fresh()->load(self::DETAIL_RELATIONS);
|
||||
}
|
||||
|
||||
public function trash(Customer $customer): void
|
||||
{
|
||||
$customer->delete();
|
||||
}
|
||||
|
||||
public function restore(Customer $customer): Customer
|
||||
{
|
||||
$customer->restore();
|
||||
|
||||
return $customer->fresh()->load(self::DETAIL_RELATIONS);
|
||||
}
|
||||
|
||||
public function forceDelete(Customer $customer): void
|
||||
{
|
||||
$customer->forceDelete();
|
||||
}
|
||||
|
||||
public function options(User $actor, ?int $tenantId): array
|
||||
{
|
||||
$scope = fn ($query) => $query
|
||||
->when(! $actor->isMasterAdmin(), fn ($q) => $q->where('tenant_id', $tenantId))
|
||||
->when($actor->isMasterAdmin() && $tenantId, fn ($q) => $q->where('tenant_id', $tenantId))
|
||||
->where('status', 'active')
|
||||
->orderBy('name');
|
||||
|
||||
return [
|
||||
'package_profiles' => $scope(NasPackageProfile::query())
|
||||
->get(['id', 'tenant_id', 'name', 'service_type', 'download_kbps', 'upload_kbps', 'price']),
|
||||
'nas_mikrotiks' => $scope(NasMikrotik::query())
|
||||
->get(['id', 'tenant_id', 'name', 'connection_type', 'host']),
|
||||
'billing_profiles' => $scope(BillingProfile::query())
|
||||
->get(['id', 'tenant_id', 'name', 'schedule_type', 'invoice_day', 'send_day', 'warning_day', 'isolation_day', 'invoice_days_before', 'send_days_before', 'warning_days_before', 'isolation_days_after', 'tax_type', 'tax_rate']),
|
||||
];
|
||||
}
|
||||
|
||||
private function nextCode(int $tenantId): string
|
||||
{
|
||||
$next = ((int) Customer::withTrashed()->where('tenant_id', $tenantId)->max('id')) + 1;
|
||||
|
||||
return 'CUST-'.str_pad((string) $next, 6, '0', STR_PAD_LEFT);
|
||||
}
|
||||
|
||||
private function validateTenantRelations(array $data): void
|
||||
{
|
||||
$tenantId = (int) $data['tenant_id'];
|
||||
if (! empty($data['package_profile_id']) && ! NasPackageProfile::whereKey($data['package_profile_id'])->where('tenant_id', $tenantId)->exists()) {
|
||||
throw ValidationException::withMessages(['package_profile_id' => 'Profile paket bukan milik tenant customer.']);
|
||||
}
|
||||
if (! empty($data['nas_mikrotik_id']) && ! NasMikrotik::whereKey($data['nas_mikrotik_id'])->where('tenant_id', $tenantId)->exists()) {
|
||||
throw ValidationException::withMessages(['nas_mikrotik_id' => 'NAS Mikrotik bukan milik tenant customer.']);
|
||||
}
|
||||
if (! empty($data['billing_profile_id']) && ! BillingProfile::whereKey($data['billing_profile_id'])->where('tenant_id', $tenantId)->exists()) {
|
||||
throw ValidationException::withMessages(['billing_profile_id' => 'Profile tagihan bukan milik tenant customer.']);
|
||||
}
|
||||
}
|
||||
|
||||
private function validateWilayah(array $data): void
|
||||
{
|
||||
if (empty($data['desa_id'])) {
|
||||
return;
|
||||
}
|
||||
$desa = WilayahDesa::with('kecamatan.kabupaten')->find($data['desa_id']);
|
||||
if (! $desa
|
||||
|| (! empty($data['kecamatan_id']) && (int) $desa->kecamatan_id !== (int) $data['kecamatan_id'])
|
||||
|| (! empty($data['kabupaten_id']) && (int) $desa->kecamatan->kabupaten_id !== (int) $data['kabupaten_id'])
|
||||
|| (! empty($data['provinsi_id']) && (int) $desa->kecamatan->kabupaten->provinsi_id !== (int) $data['provinsi_id'])) {
|
||||
throw ValidationException::withMessages(['desa_id' => 'Hierarki wilayah pemasangan tidak sesuai.']);
|
||||
}
|
||||
}
|
||||
|
||||
private function syncImages(Customer $customer, array $images, User $actor): void
|
||||
{
|
||||
$coverAssigned = false;
|
||||
$rows = collect($images)->map(function (array $image, int $index) use ($customer, $actor, &$coverAssigned) {
|
||||
$file = StoredFile::findOrFail($image['stored_file_id']);
|
||||
$allowed = $actor->isMasterAdmin()
|
||||
|| $file->uploaded_by === $actor->id
|
||||
|| ((int) $file->tenant_id === (int) $customer->tenant_id);
|
||||
if (! $allowed || $file->category !== 'customer-image' || ! str_starts_with((string) $file->mime_type, 'image/')) {
|
||||
throw ValidationException::withMessages(['images' => 'File galeri customer tidak valid atau tidak dapat diakses.']);
|
||||
}
|
||||
$isCover = ! $coverAssigned && (bool) ($image['is_cover'] ?? $index === 0);
|
||||
$coverAssigned = $coverAssigned || $isCover;
|
||||
|
||||
return [
|
||||
'stored_file_id' => $file->id,
|
||||
'category' => $image['category'],
|
||||
'caption' => $image['caption'] ?? null,
|
||||
'is_cover' => $isCover,
|
||||
'sort_order' => $image['sort_order'] ?? $index,
|
||||
];
|
||||
})->all();
|
||||
|
||||
$customer->images()->delete();
|
||||
if ($rows) {
|
||||
$customer->images()->createMany($rows);
|
||||
}
|
||||
}
|
||||
|
||||
private function syncPrimaryContacts(Customer $customer): void
|
||||
{
|
||||
foreach (['email' => $customer->email, 'whatsapp' => $customer->whatsapp_number] as $type => $value) {
|
||||
if (! $value) {
|
||||
continue;
|
||||
}
|
||||
CustomerContact::where('customer_id', $customer->id)->where('type', $type)->where('value', '!=', $value)
|
||||
->update(['is_primary' => false]);
|
||||
CustomerContact::updateOrCreate(
|
||||
['customer_id' => $customer->id, 'type' => $type, 'value' => $value],
|
||||
[
|
||||
'tenant_id' => $customer->tenant_id,
|
||||
'label' => 'Kontak utama',
|
||||
'is_primary' => true,
|
||||
'can_receive_transactional' => true,
|
||||
'can_receive_broadcast' => true,
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services\Notification\Adapters;
|
||||
|
||||
use App\Models\NotificationDelivery;
|
||||
use App\Services\Notification\Contracts\NotificationChannelAdapter;
|
||||
use Illuminate\Support\Facades\Config;
|
||||
use Illuminate\Support\Facades\Mail;
|
||||
use Illuminate\Support\Str;
|
||||
use Illuminate\Validation\ValidationException;
|
||||
|
||||
class EmailAdapter implements NotificationChannelAdapter
|
||||
{
|
||||
public function send(NotificationDelivery $delivery): array
|
||||
{
|
||||
$config = $delivery->channelConfig;
|
||||
$credentials = $config?->credentials ?? [];
|
||||
$settings = $config?->settings ?? [];
|
||||
if (! $delivery->destination || empty($settings['host'])) {
|
||||
throw ValidationException::withMessages(['channel' => 'Alamat penerima atau konfigurasi SMTP belum lengkap.']);
|
||||
}
|
||||
$original = Config::get('mail.mailers.smtp');
|
||||
$originalFrom = Config::get('mail.from');
|
||||
try {
|
||||
Config::set('mail.mailers.smtp', [
|
||||
'transport' => 'smtp',
|
||||
'host' => $settings['host'],
|
||||
'port' => $settings['port'] ?? 587,
|
||||
'encryption' => $settings['encryption'] ?? 'tls',
|
||||
'username' => $credentials['username'] ?? null,
|
||||
'password' => $credentials['password'] ?? null,
|
||||
'timeout' => 30,
|
||||
]);
|
||||
Config::set('mail.from', [
|
||||
'address' => $settings['from_address'],
|
||||
'name' => $settings['from_name'] ?? null,
|
||||
]);
|
||||
Mail::purge('smtp');
|
||||
Mail::raw($delivery->rendered_body, function ($mail) use ($delivery) {
|
||||
$mail->to($delivery->destination)->subject($delivery->rendered_subject ?: 'Notifikasi');
|
||||
});
|
||||
} finally {
|
||||
Config::set('mail.mailers.smtp', $original);
|
||||
Config::set('mail.from', $originalFrom);
|
||||
Mail::purge('smtp');
|
||||
}
|
||||
|
||||
return ['provider_message_id' => (string) Str::uuid(), 'response' => ['accepted' => true]];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services\Notification\Adapters;
|
||||
|
||||
use App\Models\NotificationChannel;
|
||||
use App\Models\NotificationDelivery;
|
||||
use App\Models\User;
|
||||
use App\Models\UserNotification;
|
||||
use App\Services\Notification\Contracts\NotificationChannelAdapter;
|
||||
use Illuminate\Support\Str;
|
||||
use Illuminate\Validation\ValidationException;
|
||||
|
||||
class SystemAdapter implements NotificationChannelAdapter
|
||||
{
|
||||
public function send(NotificationDelivery $delivery): array
|
||||
{
|
||||
$message = $delivery->message;
|
||||
$user = $message->recipient instanceof User
|
||||
? $message->recipient
|
||||
: ($message->recipient?->user ?? null);
|
||||
if (! $user) {
|
||||
throw ValidationException::withMessages(['recipient' => 'Penerima tidak memiliki akun aplikasi.']);
|
||||
}
|
||||
|
||||
$notification = UserNotification::create([
|
||||
'uuid' => Str::uuid(),
|
||||
'tenant_id' => $message->tenant_id,
|
||||
'user_id' => $user->id,
|
||||
'notification_message_id' => $message->id,
|
||||
'title' => $delivery->rendered_subject ?: 'Notifikasi',
|
||||
'body' => $delivery->rendered_body,
|
||||
'action_url' => data_get($message->context, 'action_url'),
|
||||
'icon' => data_get($message->context, 'icon', 'cil-bell'),
|
||||
]);
|
||||
$settings = NotificationChannel::where('tenant_id', $message->tenant_id)
|
||||
->where('channel', 'system')->where('enabled', true)->where('is_default', true)
|
||||
->first()?->settings ?? [];
|
||||
$maximum = max(20, (int) ($settings['maximum_notifications_per_user'] ?? 500));
|
||||
UserNotification::where('user_id', $user->id)
|
||||
->whereNotIn('id', UserNotification::where('user_id', $user->id)->latest('id')->limit($maximum)->pluck('id'))
|
||||
->delete();
|
||||
|
||||
return ['provider_message_id' => $notification->uuid, 'response' => ['stored' => true]];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services\Notification\Adapters;
|
||||
|
||||
use App\Models\NotificationDelivery;
|
||||
use App\Services\Notification\Contracts\NotificationChannelAdapter;
|
||||
use Illuminate\Support\Facades\Http;
|
||||
use Illuminate\Validation\ValidationException;
|
||||
|
||||
class TelegramAdapter implements NotificationChannelAdapter
|
||||
{
|
||||
public function send(NotificationDelivery $delivery): array
|
||||
{
|
||||
$token = data_get($delivery->channelConfig?->credentials, 'bot_token');
|
||||
if (! $token) {
|
||||
throw ValidationException::withMessages(['channel' => 'Bot token Telegram belum diatur.']);
|
||||
}
|
||||
$response = Http::timeout(30)->post("https://api.telegram.org/bot{$token}/sendMessage", [
|
||||
'chat_id' => $delivery->destination,
|
||||
'text' => $delivery->rendered_body,
|
||||
'parse_mode' => data_get($delivery->channelConfig?->settings, 'parse_mode'),
|
||||
])->throw()->json();
|
||||
|
||||
return ['provider_message_id' => (string) data_get($response, 'result.message_id'), 'response' => $response];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services\Notification\Adapters;
|
||||
|
||||
use App\Models\NotificationDelivery;
|
||||
use App\Services\Notification\Contracts\NotificationChannelAdapter;
|
||||
use Illuminate\Support\Facades\Http;
|
||||
use Illuminate\Validation\ValidationException;
|
||||
|
||||
class WhatsAppOfficialAdapter implements NotificationChannelAdapter
|
||||
{
|
||||
public function send(NotificationDelivery $delivery): array
|
||||
{
|
||||
$config = $delivery->channelConfig;
|
||||
$credentials = $config?->credentials ?? [];
|
||||
$settings = $config?->settings ?? [];
|
||||
$phoneId = $credentials['phone_number_id'] ?? null;
|
||||
$token = $credentials['access_token'] ?? null;
|
||||
$templateName = data_get($delivery->provider_response, 'request_context.provider_template_name');
|
||||
if (! $phoneId || ! $token || ! $templateName) {
|
||||
throw ValidationException::withMessages(['channel' => 'Phone Number ID, access token, dan template WhatsApp Official wajib tersedia.']);
|
||||
}
|
||||
$baseUrl = rtrim($settings['base_url'] ?? 'https://graph.facebook.com/v22.0', '/');
|
||||
$parameters = collect(data_get($delivery->provider_response, 'request_context.template_parameters', []))
|
||||
->map(fn ($value) => ['type' => 'text', 'text' => (string) $value])->values()->all();
|
||||
$response = Http::withToken($token)->timeout(30)->post("{$baseUrl}/{$phoneId}/messages", [
|
||||
'messaging_product' => 'whatsapp',
|
||||
'to' => $delivery->destination,
|
||||
'type' => 'template',
|
||||
'template' => [
|
||||
'name' => $templateName,
|
||||
'language' => ['code' => data_get($delivery->provider_response, 'request_context.language', 'id')],
|
||||
'components' => $parameters ? [['type' => 'body', 'parameters' => $parameters]] : [],
|
||||
],
|
||||
])->throw()->json();
|
||||
|
||||
return ['provider_message_id' => data_get($response, 'messages.0.id'), 'response' => $response];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services\Notification\Adapters;
|
||||
|
||||
use App\Models\NotificationDelivery;
|
||||
use App\Services\Notification\Contracts\NotificationChannelAdapter;
|
||||
use Illuminate\Support\Facades\Http;
|
||||
use Illuminate\Validation\ValidationException;
|
||||
|
||||
class WhatsAppUnofficialAdapter implements NotificationChannelAdapter
|
||||
{
|
||||
public function send(NotificationDelivery $delivery): array
|
||||
{
|
||||
$config = $delivery->channelConfig;
|
||||
$credentials = $config?->credentials ?? [];
|
||||
$settings = $config?->settings ?? [];
|
||||
$url = $settings['send_url'] ?? null;
|
||||
if (! $url) {
|
||||
throw ValidationException::withMessages(['channel' => 'URL pengiriman gateway unofficial belum diatur.']);
|
||||
}
|
||||
$headers = [];
|
||||
if ($apiKey = $credentials['api_key'] ?? null) {
|
||||
$headers[$settings['api_key_header'] ?? 'X-API-Key'] = $apiKey;
|
||||
}
|
||||
$payload = [
|
||||
$settings['destination_field'] ?? 'number' => $delivery->destination,
|
||||
$settings['message_field'] ?? 'message' => $delivery->rendered_body,
|
||||
'instance_id' => $credentials['instance_id'] ?? null,
|
||||
];
|
||||
$response = Http::withHeaders($headers)->timeout(30)->post($url, $payload)->throw()->json();
|
||||
|
||||
return ['provider_message_id' => data_get($response, $settings['message_id_path'] ?? 'id'), 'response' => $response];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services\Notification\Contracts;
|
||||
|
||||
use App\Models\NotificationDelivery;
|
||||
|
||||
interface NotificationChannelAdapter
|
||||
{
|
||||
public function send(NotificationDelivery $delivery): array;
|
||||
}
|
||||
@@ -0,0 +1,294 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services\Notification;
|
||||
|
||||
use App\Jobs\ProcessNotificationDelivery;
|
||||
use App\Models\Customer;
|
||||
use App\Models\NotificationBroadcastRecipient;
|
||||
use App\Models\NotificationChannel;
|
||||
use App\Models\NotificationDelivery;
|
||||
use App\Models\NotificationEvent;
|
||||
use App\Models\NotificationEventOccurrence;
|
||||
use App\Models\NotificationMessage;
|
||||
use App\Models\NotificationPreference;
|
||||
use App\Models\NotificationTemplate;
|
||||
use App\Models\User;
|
||||
use App\Services\Notification\Adapters\EmailAdapter;
|
||||
use App\Services\Notification\Adapters\SystemAdapter;
|
||||
use App\Services\Notification\Adapters\TelegramAdapter;
|
||||
use App\Services\Notification\Adapters\WhatsAppOfficialAdapter;
|
||||
use App\Services\Notification\Adapters\WhatsAppUnofficialAdapter;
|
||||
use Carbon\Carbon;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Str;
|
||||
|
||||
class NotificationManager
|
||||
{
|
||||
public function emit(
|
||||
string $eventCode,
|
||||
Model $recipient,
|
||||
Model $subject,
|
||||
array $payload,
|
||||
string $occurrenceKey,
|
||||
array $context = [],
|
||||
): ?NotificationMessage {
|
||||
$event = NotificationEvent::where('code', $eventCode)->where('enabled', true)->first();
|
||||
$tenantId = (int) ($recipient->tenant_id ?? $subject->tenant_id);
|
||||
if (! $event || ! $tenantId) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return DB::transaction(function () use ($event, $recipient, $subject, $payload, $occurrenceKey, $context, $tenantId) {
|
||||
$occurrence = NotificationEventOccurrence::firstOrCreate([
|
||||
'tenant_id' => $tenantId,
|
||||
'notification_event_id' => $event->id,
|
||||
'subjectable_type' => $subject->getMorphClass(),
|
||||
'subjectable_id' => $subject->getKey(),
|
||||
'occurrence_key' => $occurrenceKey,
|
||||
]);
|
||||
if (! $occurrence->wasRecentlyCreated) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$preferences = NotificationPreference::with('channelConfig')
|
||||
->where('tenant_id', $tenantId)
|
||||
->where('notification_event_id', $event->id)
|
||||
->where('enabled', true)
|
||||
->orderBy('priority')
|
||||
->get();
|
||||
if ($preferences->isEmpty()) {
|
||||
$occurrence->update(['processed_at' => now()]);
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
$message = NotificationMessage::create([
|
||||
'uuid' => Str::uuid(),
|
||||
'tenant_id' => $tenantId,
|
||||
'notification_event_id' => $event->id,
|
||||
'recipient_type' => $recipient->getMorphClass(),
|
||||
'recipient_id' => $recipient->getKey(),
|
||||
'subjectable_type' => $subject->getMorphClass(),
|
||||
'subjectable_id' => $subject->getKey(),
|
||||
'payload' => $payload,
|
||||
'context' => $context,
|
||||
'priority' => 5,
|
||||
'status' => 'queued',
|
||||
]);
|
||||
|
||||
foreach ($preferences as $preference) {
|
||||
$template = $this->template($tenantId, $event->id, $preference->channel);
|
||||
$channelConfig = $preference->channelConfig
|
||||
?: NotificationChannel::where('tenant_id', $tenantId)->where('channel', $preference->channel)->where('enabled', true)->where('is_default', true)->first();
|
||||
$destination = $this->destination($recipient, $preference->channel);
|
||||
if (! $template || ($preference->channel !== 'system' && ! $channelConfig) || ! $destination) {
|
||||
NotificationDelivery::create([
|
||||
'uuid' => Str::uuid(),
|
||||
'notification_message_id' => $message->id,
|
||||
'notification_channel_id' => $channelConfig?->id,
|
||||
'channel' => $preference->channel,
|
||||
'destination' => $destination,
|
||||
'rendered_subject' => $template?->subject,
|
||||
'rendered_body' => $template?->body ?? '',
|
||||
'status' => 'skipped',
|
||||
'error_message' => 'Template, channel, atau tujuan penerima belum tersedia.',
|
||||
]);
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
$delivery = NotificationDelivery::create([
|
||||
'uuid' => Str::uuid(),
|
||||
'notification_message_id' => $message->id,
|
||||
'notification_channel_id' => $channelConfig?->id,
|
||||
'channel' => $preference->channel,
|
||||
'destination' => $destination,
|
||||
'rendered_subject' => $this->render($template->subject, $payload),
|
||||
'rendered_body' => $this->render($template->body, $payload),
|
||||
'status' => 'queued',
|
||||
'queued_at' => now(),
|
||||
'provider_response' => [
|
||||
'request_context' => [
|
||||
'provider_template_name' => $template->provider_template_name,
|
||||
'language' => $template->language,
|
||||
'template_parameters' => collect($template->variables ?? [])->map(fn ($key) => data_get($payload, $key))->values()->all(),
|
||||
],
|
||||
],
|
||||
]);
|
||||
$scheduledAt = $this->scheduledAt($preference);
|
||||
$message->update([
|
||||
'notification_template_id' => $message->notification_template_id ?: $template->id,
|
||||
'scheduled_at' => $scheduledAt,
|
||||
]);
|
||||
ProcessNotificationDelivery::dispatch($delivery->id)
|
||||
->delay($scheduledAt)
|
||||
->onQueue('notifications-'.$this->queueName($preference->channel))
|
||||
->afterCommit();
|
||||
}
|
||||
$occurrence->update(['processed_at' => now()]);
|
||||
|
||||
return $message->load('deliveries');
|
||||
});
|
||||
}
|
||||
|
||||
public function deliver(NotificationDelivery $delivery): void
|
||||
{
|
||||
$delivery->load(['message.recipient', 'channelConfig']);
|
||||
$delivery->update(['status' => 'processing', 'attempt_count' => $delivery->attempt_count + 1]);
|
||||
try {
|
||||
$result = $this->adapter($delivery->channel)->send($delivery);
|
||||
$delivery->update([
|
||||
'status' => $delivery->channel === 'system' ? 'delivered' : 'sent',
|
||||
'provider_message_id' => $result['provider_message_id'] ?? null,
|
||||
'provider_response' => $result['response'] ?? null,
|
||||
'sent_at' => now(),
|
||||
'delivered_at' => $delivery->channel === 'system' ? now() : null,
|
||||
'error_code' => null,
|
||||
'error_message' => null,
|
||||
]);
|
||||
} catch (\Throwable $exception) {
|
||||
$delivery->update([
|
||||
'status' => 'failed',
|
||||
'failed_at' => now(),
|
||||
'next_retry_at' => now()->addMinutes(min(60, 2 ** $delivery->attempt_count)),
|
||||
'error_code' => (string) $exception->getCode(),
|
||||
'error_message' => Str::limit($exception->getMessage(), 2000),
|
||||
]);
|
||||
throw $exception;
|
||||
} finally {
|
||||
$this->refreshMessageStatus($delivery->message);
|
||||
$this->refreshBroadcast($delivery->message);
|
||||
}
|
||||
}
|
||||
|
||||
public function render(?string $template, array $payload): ?string
|
||||
{
|
||||
if ($template === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return preg_replace_callback(
|
||||
'/{{\s*([a-zA-Z0-9_.]+)\s*}}/',
|
||||
function ($matches) use ($payload) {
|
||||
$value = data_get($payload, $matches[1]);
|
||||
if ($value === null && $matches[1] === 'tenant.name') {
|
||||
$value = data_get($payload, 'tenant.tenant_name');
|
||||
}
|
||||
|
||||
return (string) ($value ?? '');
|
||||
},
|
||||
$template,
|
||||
);
|
||||
}
|
||||
|
||||
public function destination(Model $recipient, string $channel, bool $broadcast = false): ?string
|
||||
{
|
||||
if ($channel === 'system') {
|
||||
return (string) ($recipient instanceof User ? $recipient->id : $recipient->user_id);
|
||||
}
|
||||
if (! $recipient instanceof Customer) {
|
||||
return $channel === 'email' ? $recipient->email : null;
|
||||
}
|
||||
$type = match ($channel) {
|
||||
'whatsapp_official', 'whatsapp_unofficial' => 'whatsapp',
|
||||
'email' => 'email',
|
||||
'telegram' => 'telegram',
|
||||
};
|
||||
$contact = $recipient->contacts()
|
||||
->where('type', $type)
|
||||
->whereNull('opted_out_at')
|
||||
->when($broadcast, fn ($q) => $q->where('can_receive_broadcast', true))
|
||||
->orderByDesc('is_primary')
|
||||
->first();
|
||||
if ($contact) {
|
||||
return $contact->value;
|
||||
}
|
||||
|
||||
return match ($channel) {
|
||||
'whatsapp_official', 'whatsapp_unofficial' => $broadcast ? null : $recipient->whatsapp_number,
|
||||
'email' => $broadcast ? null : $recipient->email,
|
||||
default => null,
|
||||
};
|
||||
}
|
||||
|
||||
private function template(int $tenantId, int $eventId, string $channel): ?NotificationTemplate
|
||||
{
|
||||
return NotificationTemplate::where('notification_event_id', $eventId)
|
||||
->where('channel', $channel)
|
||||
->where('enabled', true)
|
||||
->when($channel === 'whatsapp_official', fn ($q) => $q->where('approval_status', 'approved')->whereNotNull('provider_template_name'))
|
||||
->where(fn ($q) => $q->where('tenant_id', $tenantId)->orWhereNull('tenant_id'))
|
||||
->orderByRaw('tenant_id IS NULL')
|
||||
->orderByDesc('version')
|
||||
->first();
|
||||
}
|
||||
|
||||
private function adapter(string $channel)
|
||||
{
|
||||
return match ($channel) {
|
||||
'system' => app(SystemAdapter::class),
|
||||
'whatsapp_official' => app(WhatsAppOfficialAdapter::class),
|
||||
'whatsapp_unofficial' => app(WhatsAppUnofficialAdapter::class),
|
||||
'email' => app(EmailAdapter::class),
|
||||
'telegram' => app(TelegramAdapter::class),
|
||||
};
|
||||
}
|
||||
|
||||
private function queueName(string $channel): string
|
||||
{
|
||||
return str_starts_with($channel, 'whatsapp') ? 'whatsapp' : $channel;
|
||||
}
|
||||
|
||||
private function scheduledAt(NotificationPreference $preference): Carbon
|
||||
{
|
||||
$scheduled = now()->addMinutes($preference->send_delay_minutes);
|
||||
if (! $preference->quiet_hours_start || ! $preference->quiet_hours_end) {
|
||||
return $scheduled;
|
||||
}
|
||||
$start = $scheduled->copy()->setTimeFromTimeString($preference->quiet_hours_start);
|
||||
$end = $scheduled->copy()->setTimeFromTimeString($preference->quiet_hours_end);
|
||||
if ($end->lte($start)) {
|
||||
$end->addDay();
|
||||
if ($scheduled->lt($start)) {
|
||||
$start->subDay();
|
||||
}
|
||||
}
|
||||
|
||||
return $scheduled->between($start, $end) ? $end : $scheduled;
|
||||
}
|
||||
|
||||
private function refreshMessageStatus(NotificationMessage $message): void
|
||||
{
|
||||
$statuses = $message->deliveries()->pluck('status');
|
||||
$status = $statuses->contains(fn ($value) => in_array($value, ['queued', 'processing', 'pending'], true))
|
||||
? 'processing'
|
||||
: ($statuses->contains(fn ($value) => in_array($value, ['sent', 'delivered', 'read'], true)) ? 'sent' : 'failed');
|
||||
$message->update(['status' => $status]);
|
||||
}
|
||||
|
||||
private function refreshBroadcast(NotificationMessage $message): void
|
||||
{
|
||||
if (! $message->notification_broadcast_id) {
|
||||
return;
|
||||
}
|
||||
$recipient = NotificationBroadcastRecipient::where('notification_message_id', $message->id)->first();
|
||||
$messageStatus = $message->fresh()->status;
|
||||
if ($recipient) {
|
||||
$recipient->update([
|
||||
'status' => $messageStatus === 'sent' ? 'sent' : ($messageStatus === 'failed' ? 'failed' : 'queued'),
|
||||
]);
|
||||
}
|
||||
$broadcast = $message->broadcast;
|
||||
if (! $broadcast) {
|
||||
return;
|
||||
}
|
||||
$pending = $broadcast->recipients()->whereIn('status', ['pending', 'queued'])->count();
|
||||
$broadcast->update([
|
||||
'processed_recipients' => $broadcast->recipients()->count(),
|
||||
'success_count' => $broadcast->recipients()->where('status', 'sent')->count(),
|
||||
'failed_count' => $broadcast->recipients()->whereIn('status', ['failed', 'skipped'])->count(),
|
||||
'status' => $pending === 0 ? 'completed' : 'processing',
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,395 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services\Notification;
|
||||
|
||||
use App\Jobs\ProcessBroadcast;
|
||||
use App\Jobs\ProcessNotificationDelivery;
|
||||
use App\Models\Customer;
|
||||
use App\Models\CustomerContact;
|
||||
use App\Models\NotificationBroadcast;
|
||||
use App\Models\NotificationBroadcastRecipient;
|
||||
use App\Models\NotificationChannel;
|
||||
use App\Models\NotificationDelivery;
|
||||
use App\Models\NotificationEvent;
|
||||
use App\Models\NotificationMessage;
|
||||
use App\Models\NotificationPreference;
|
||||
use App\Models\NotificationTemplate;
|
||||
use App\Models\TopologyLink;
|
||||
use App\Models\User;
|
||||
use App\Models\UserNotification;
|
||||
use Illuminate\Database\Eloquent\Builder;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Http;
|
||||
use Illuminate\Support\Str;
|
||||
use Illuminate\Validation\ValidationException;
|
||||
|
||||
class NotificationService
|
||||
{
|
||||
public function __construct(protected NotificationManager $manager) {}
|
||||
|
||||
public function channels(string $channel, User $actor, ?int $tenantId)
|
||||
{
|
||||
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))
|
||||
->orderByDesc('is_default')->orderBy('name')->get()
|
||||
->each(fn ($item) => $item->setAttribute('credentials_configured', ! empty($item->getRawOriginal('credentials'))));
|
||||
}
|
||||
|
||||
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) {
|
||||
throw ValidationException::withMessages(['tenant_id' => 'Tenant wajib dipilih.']);
|
||||
}
|
||||
if ($channel && (int) $channel->tenant_id !== (int) $effectiveTenant) {
|
||||
throw ValidationException::withMessages(['tenant_id' => 'Konfigurasi bukan milik tenant tersebut.']);
|
||||
}
|
||||
$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'])
|
||||
->when($channel, fn ($q) => $q->whereKeyNot($channel->id))
|
||||
->update(['is_default' => false]);
|
||||
}
|
||||
$payload = [
|
||||
...$data,
|
||||
'tenant_id' => $effectiveTenant,
|
||||
'provider' => $data['provider'] ?? $data['channel'],
|
||||
'settings' => $data['settings'] ?? [],
|
||||
];
|
||||
if ($credentials) {
|
||||
$payload['credentials'] = $credentials;
|
||||
} else {
|
||||
unset($payload['credentials']);
|
||||
}
|
||||
$channel ? $channel->update($payload) : $channel = NotificationChannel::create($payload);
|
||||
|
||||
return $channel->fresh('tenant:id,tenant_code,tenant_name')
|
||||
->setAttribute('credentials_configured', ! empty($channel->getRawOriginal('credentials')));
|
||||
}
|
||||
|
||||
public function templates(string $channel, User $actor, ?int $tenantId)
|
||||
{
|
||||
return NotificationTemplate::with(['event:id,code,name,category,variables', 'channelConfig:id,name'])
|
||||
->where('channel', $channel)
|
||||
->where(function ($q) use ($actor, $tenantId) {
|
||||
$q->whereNull('tenant_id')
|
||||
->when(! $actor->isMasterAdmin() || $tenantId, fn ($x) => $x->orWhere('tenant_id', $tenantId));
|
||||
})
|
||||
->orderBy('notification_event_id')->orderByDesc('tenant_id')->orderByDesc('version')->get();
|
||||
}
|
||||
|
||||
public function saveTemplate(array $data, User $actor, ?int $tenantId, ?NotificationTemplate $template = null): NotificationTemplate
|
||||
{
|
||||
$effectiveTenant = $actor->isMasterAdmin() ? ($data['tenant_id'] ?? $template?->tenant_id ?? $tenantId) : $tenantId;
|
||||
if (! $effectiveTenant) {
|
||||
throw ValidationException::withMessages(['tenant_id' => 'Tenant wajib dipilih untuk membuat override template.']);
|
||||
}
|
||||
if ($template?->tenant_id === null) {
|
||||
$template = null;
|
||||
}
|
||||
$variables = $this->extractVariables(($data['subject'] ?? '').' '.($data['body'] ?? ''));
|
||||
$payload = [...$data, 'tenant_id' => $effectiveTenant, 'variables' => $variables];
|
||||
if (! $template) {
|
||||
$payload['version'] = ((int) NotificationTemplate::where('tenant_id', $effectiveTenant)
|
||||
->where('notification_event_id', $data['notification_event_id'])
|
||||
->where('channel', $data['channel'])->max('version')) + 1;
|
||||
$template = NotificationTemplate::create($payload);
|
||||
} else {
|
||||
$template->update($payload);
|
||||
}
|
||||
|
||||
return $template->fresh(['event:id,code,name,category,variables', 'channelConfig:id,name']);
|
||||
}
|
||||
|
||||
public function preferences(User $actor, ?int $tenantId): array
|
||||
{
|
||||
$effectiveTenant = $actor->isMasterAdmin() ? $tenantId : $tenantId;
|
||||
|
||||
return [
|
||||
'events' => NotificationEvent::where('enabled', true)->orderBy('category')->orderBy('name')->get(),
|
||||
'preferences' => $effectiveTenant
|
||||
? NotificationPreference::with(['event:id,code,name', 'channelConfig:id,name'])->where('tenant_id', $effectiveTenant)->get()
|
||||
: [],
|
||||
];
|
||||
}
|
||||
|
||||
public function savePreferences(array $rows, User $actor, ?int $tenantId): void
|
||||
{
|
||||
DB::transaction(function () use ($rows, $actor, $tenantId) {
|
||||
foreach ($rows as $row) {
|
||||
$effectiveTenant = $actor->isMasterAdmin() ? ($row['tenant_id'] ?? $tenantId) : $tenantId;
|
||||
if (! $effectiveTenant) {
|
||||
throw ValidationException::withMessages(['tenant_id' => 'Tenant wajib dipilih.']);
|
||||
}
|
||||
if (! empty($row['notification_channel_id']) && ! NotificationChannel::whereKey($row['notification_channel_id'])
|
||||
->where('tenant_id', $effectiveTenant)->where('channel', $row['channel'])->exists()) {
|
||||
throw ValidationException::withMessages(['notification_channel_id' => 'Konfigurasi channel bukan milik tenant atau channel tersebut.']);
|
||||
}
|
||||
NotificationPreference::updateOrCreate(
|
||||
[
|
||||
'tenant_id' => $effectiveTenant,
|
||||
'notification_event_id' => $row['notification_event_id'],
|
||||
'channel' => $row['channel'],
|
||||
],
|
||||
[
|
||||
'notification_channel_id' => $row['notification_channel_id'] ?? null,
|
||||
'enabled' => $row['enabled'] ?? false,
|
||||
'priority' => $row['priority'] ?? 1,
|
||||
'send_delay_minutes' => $row['send_delay_minutes'] ?? 0,
|
||||
'quiet_hours_start' => $row['quiet_hours_start'] ?? null,
|
||||
'quiet_hours_end' => $row['quiet_hours_end'] ?? null,
|
||||
],
|
||||
);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
public function deliveries(string $channel, array $filters, User $actor, ?int $tenantId)
|
||||
{
|
||||
return NotificationDelivery::with(['message:id,tenant_id,uuid,notification_event_id,recipient_type,recipient_id,created_at', 'message.event:id,code,name', 'channelConfig:id,name'])
|
||||
->where('channel', $channel)
|
||||
->whereHas('message', fn ($q) => $q
|
||||
->when(! $actor->isMasterAdmin(), fn ($x) => $x->where('tenant_id', $tenantId))
|
||||
->when($actor->isMasterAdmin() && $tenantId, fn ($x) => $x->where('tenant_id', $tenantId)))
|
||||
->when(! empty($filters['status']), fn ($q) => $q->where('status', $filters['status']))
|
||||
->when(! empty($filters['search']), fn ($q) => $q->where(fn ($x) => $x->where('destination', 'ILIKE', "%{$filters['search']}%")->orWhere('rendered_body', 'ILIKE', "%{$filters['search']}%")))
|
||||
->orderByDesc('id')->paginate($filters['per_page'] ?? 10);
|
||||
}
|
||||
|
||||
public function testChannel(NotificationChannel $channel): NotificationChannel
|
||||
{
|
||||
try {
|
||||
$credentials = $channel->credentials ?? [];
|
||||
$settings = $channel->settings ?? [];
|
||||
$message = match ($channel->channel) {
|
||||
'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.'),
|
||||
'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()]);
|
||||
} catch (\Throwable $exception) {
|
||||
$channel->update(['last_health_status' => 'unhealthy', 'last_health_message' => Str::limit($exception->getMessage(), 1000), 'last_health_check_at' => now()]);
|
||||
}
|
||||
|
||||
return $channel->fresh();
|
||||
}
|
||||
|
||||
public function broadcasts(array $filters, User $actor, ?int $tenantId)
|
||||
{
|
||||
return NotificationBroadcast::with(['tenant:id,tenant_code,tenant_name', 'template:id,name', 'channelConfig:id,name'])
|
||||
->when(! $actor->isMasterAdmin(), fn ($q) => $q->where('tenant_id', $tenantId))
|
||||
->when($actor->isMasterAdmin() && $tenantId, fn ($q) => $q->where('tenant_id', $tenantId))
|
||||
->when(! empty($filters['status']), fn ($q) => $q->where('status', $filters['status']))
|
||||
->when(! empty($filters['search']), fn ($q) => $q->where('name', 'ILIKE', "%{$filters['search']}%"))
|
||||
->orderByDesc('id')->paginate($filters['per_page'] ?? 10);
|
||||
}
|
||||
|
||||
public function previewBroadcast(array $data, User $actor, ?int $tenantId): array
|
||||
{
|
||||
$effectiveTenant = $actor->isMasterAdmin() ? ($data['tenant_id'] ?? $tenantId) : $tenantId;
|
||||
if (! $effectiveTenant) {
|
||||
throw ValidationException::withMessages(['tenant_id' => 'Tenant wajib dipilih.']);
|
||||
}
|
||||
$query = $this->recipientQuery($effectiveTenant, $data['filters'] ?? []);
|
||||
$estimated = (clone $query)->count();
|
||||
$sample = $query->limit(10)->get(['id', 'customer_code', 'name', 'email', 'whatsapp_number']);
|
||||
|
||||
return ['estimated_recipients' => $estimated, 'sample' => $sample];
|
||||
}
|
||||
|
||||
public function createBroadcast(array $data, User $actor, ?int $tenantId): NotificationBroadcast
|
||||
{
|
||||
$effectiveTenant = $actor->isMasterAdmin() ? ($data['tenant_id'] ?? $tenantId) : $tenantId;
|
||||
if (! $effectiveTenant) {
|
||||
throw ValidationException::withMessages(['tenant_id' => 'Tenant wajib dipilih.']);
|
||||
}
|
||||
$channelConfig = NotificationChannel::whereKey($data['notification_channel_id'] ?? null)
|
||||
->where('tenant_id', $effectiveTenant)->where('channel', $data['channel'])->where('enabled', true)->first();
|
||||
if ($data['channel'] !== 'system' && ! $channelConfig) {
|
||||
throw ValidationException::withMessages(['notification_channel_id' => 'Konfigurasi channel aktif wajib dipilih.']);
|
||||
}
|
||||
$template = null;
|
||||
if (! empty($data['notification_template_id'])) {
|
||||
$template = NotificationTemplate::whereKey($data['notification_template_id'])
|
||||
->where('channel', $data['channel'])
|
||||
->where('enabled', true)
|
||||
->where(fn ($q) => $q->whereNull('tenant_id')->orWhere('tenant_id', $effectiveTenant))
|
||||
->first();
|
||||
if (! $template) {
|
||||
throw ValidationException::withMessages(['notification_template_id' => 'Template tidak tersedia untuk tenant dan channel ini.']);
|
||||
}
|
||||
}
|
||||
if ($data['channel'] === 'whatsapp_official'
|
||||
&& (! $template || $template->approval_status !== 'approved' || ! $template->provider_template_name)) {
|
||||
throw ValidationException::withMessages(['notification_template_id' => 'WhatsApp Official wajib memakai template provider berstatus approved.']);
|
||||
}
|
||||
$estimated = $this->recipientQuery($effectiveTenant, $data['filters'] ?? [])->count();
|
||||
$filters = $data['filters'] ?? [];
|
||||
unset($data['filters']);
|
||||
$requiresApproval = $estimated >= 1000;
|
||||
$broadcast = NotificationBroadcast::create([
|
||||
...$data,
|
||||
'uuid' => Str::uuid(),
|
||||
'tenant_id' => $effectiveTenant,
|
||||
'filter_snapshot' => $filters,
|
||||
'estimated_recipients' => $estimated,
|
||||
'status' => $requiresApproval
|
||||
? 'pending_approval'
|
||||
: (! empty($data['scheduled_at']) && now()->lt($data['scheduled_at']) ? 'scheduled' : 'queued'),
|
||||
'created_by' => $actor->id,
|
||||
]);
|
||||
if (! $requiresApproval) {
|
||||
$this->dispatchBroadcast($broadcast);
|
||||
}
|
||||
|
||||
return $broadcast->load(['tenant:id,tenant_code,tenant_name', 'template:id,name', 'channelConfig:id,name']);
|
||||
}
|
||||
|
||||
public function approveBroadcast(NotificationBroadcast $broadcast, User $actor): NotificationBroadcast
|
||||
{
|
||||
if ($broadcast->status !== 'pending_approval') {
|
||||
throw ValidationException::withMessages(['broadcast' => 'Siaran ini tidak sedang menunggu persetujuan.']);
|
||||
}
|
||||
$broadcast->update([
|
||||
'approved_by' => $actor->id,
|
||||
'status' => $broadcast->scheduled_at?->isFuture() ? 'scheduled' : 'queued',
|
||||
]);
|
||||
$this->dispatchBroadcast($broadcast);
|
||||
|
||||
return $broadcast->fresh(['tenant:id,tenant_code,tenant_name', 'template:id,name', 'channelConfig:id,name']);
|
||||
}
|
||||
|
||||
public function processBroadcast(NotificationBroadcast $broadcast): void
|
||||
{
|
||||
if (in_array($broadcast->status, ['processing', 'completed', 'cancelled'], true)) {
|
||||
return;
|
||||
}
|
||||
$broadcast->update(['status' => 'processing']);
|
||||
$template = $broadcast->template;
|
||||
$channelConfig = $broadcast->channelConfig;
|
||||
$this->recipientQuery($broadcast->tenant_id, $broadcast->filter_snapshot)
|
||||
->with('contacts')
|
||||
->chunkById(200, function ($customers) use ($broadcast, $template, $channelConfig) {
|
||||
foreach ($customers as $customer) {
|
||||
$destination = $this->manager->destination($customer, $broadcast->channel, true);
|
||||
$recipient = NotificationBroadcastRecipient::firstOrCreate(
|
||||
['notification_broadcast_id' => $broadcast->id, 'customer_id' => $customer->id],
|
||||
['destination' => $destination, 'status' => $destination ? 'pending' : 'skipped', 'reason' => $destination ? null : 'Kontak broadcast tidak tersedia.'],
|
||||
);
|
||||
if (! $destination || $recipient->notification_message_id) {
|
||||
continue;
|
||||
}
|
||||
$payload = ['customer' => $customer->toArray(), 'tenant' => $broadcast->tenant->toArray()];
|
||||
$body = $this->manager->render($template?->body ?? $broadcast->body, $payload);
|
||||
$subject = $this->manager->render($template?->subject ?? $broadcast->subject, $payload);
|
||||
$message = NotificationMessage::create([
|
||||
'uuid' => Str::uuid(), 'tenant_id' => $broadcast->tenant_id,
|
||||
'notification_template_id' => $template?->id, 'notification_broadcast_id' => $broadcast->id,
|
||||
'recipient_type' => $customer->getMorphClass(), 'recipient_id' => $customer->id,
|
||||
'subject' => $subject, 'payload' => $payload, 'status' => 'queued', 'scheduled_at' => now(),
|
||||
'context' => ['provider_template_name' => $template?->provider_template_name, 'language' => $template?->language],
|
||||
]);
|
||||
$delivery = NotificationDelivery::create([
|
||||
'uuid' => Str::uuid(), 'notification_message_id' => $message->id,
|
||||
'notification_channel_id' => $channelConfig?->id, 'channel' => $broadcast->channel,
|
||||
'destination' => $destination, 'rendered_subject' => $subject, 'rendered_body' => $body,
|
||||
'status' => 'queued', 'queued_at' => now(),
|
||||
'provider_response' => ['request_context' => [
|
||||
'provider_template_name' => $template?->provider_template_name,
|
||||
'language' => $template?->language,
|
||||
'template_parameters' => collect($template?->variables ?? [])->map(fn ($key) => data_get($payload, $key))->values()->all(),
|
||||
]],
|
||||
]);
|
||||
$recipient->update(['notification_message_id' => $message->id, 'status' => 'queued']);
|
||||
$queue = str_starts_with($broadcast->channel, 'whatsapp') ? 'whatsapp' : $broadcast->channel;
|
||||
ProcessNotificationDelivery::dispatch($delivery->id)->onQueue('notifications-'.$queue);
|
||||
}
|
||||
});
|
||||
$pending = $broadcast->recipients()->whereIn('status', ['pending', 'queued'])->count();
|
||||
$broadcast->update([
|
||||
'status' => $pending === 0 ? 'completed' : 'processing',
|
||||
'processed_recipients' => $broadcast->recipients()->count(),
|
||||
'success_count' => $broadcast->recipients()->where('status', 'sent')->count(),
|
||||
'failed_count' => $broadcast->recipients()->whereIn('status', ['failed', 'skipped'])->count(),
|
||||
]);
|
||||
}
|
||||
|
||||
public function userNotifications(User $user, array $filters)
|
||||
{
|
||||
return UserNotification::where('user_id', $user->id)
|
||||
->when(($filters['unread'] ?? false), fn ($q) => $q->whereNull('read_at'))
|
||||
->orderByDesc('id')->paginate($filters['per_page'] ?? 20);
|
||||
}
|
||||
|
||||
public function saveContact(Customer $customer, array $data, ?CustomerContact $contact = null): CustomerContact
|
||||
{
|
||||
if ($contact && (int) $contact->customer_id !== (int) $customer->id) {
|
||||
throw ValidationException::withMessages(['contact' => 'Kontak bukan milik customer tersebut.']);
|
||||
}
|
||||
if (($data['is_primary'] ?? false) === true) {
|
||||
$customer->contacts()->where('type', $data['type'])->when($contact, fn ($q) => $q->whereKeyNot($contact->id))
|
||||
->update(['is_primary' => false]);
|
||||
}
|
||||
$data = [...$data, 'tenant_id' => $customer->tenant_id];
|
||||
if (($data['is_verified'] ?? false) && ! $contact?->verified_at) {
|
||||
$data['verified_at'] = now();
|
||||
}
|
||||
$contact ? $contact->update($data) : $contact = $customer->contacts()->create($data);
|
||||
|
||||
return $contact->fresh();
|
||||
}
|
||||
|
||||
private function recipientQuery(int $tenantId, array $filters): Builder
|
||||
{
|
||||
$query = Customer::query()->where('tenant_id', $tenantId)->whereNull('deleted_at');
|
||||
$query->when(! empty($filters['customer_status']), fn ($q) => $q->whereIn('status', (array) $filters['customer_status']))
|
||||
->when(! empty($filters['source']), fn ($q) => $q->where('source', $filters['source']))
|
||||
->when(! empty($filters['package_profile_id']), fn ($q) => $q->where('package_profile_id', $filters['package_profile_id']))
|
||||
->when(! empty($filters['billing_profile_id']), fn ($q) => $q->where('billing_profile_id', $filters['billing_profile_id']))
|
||||
->when(! empty($filters['provinsi_id']), fn ($q) => $q->where('provinsi_id', $filters['provinsi_id']))
|
||||
->when(! empty($filters['kabupaten_id']), fn ($q) => $q->where('kabupaten_id', $filters['kabupaten_id']))
|
||||
->when(! empty($filters['kecamatan_id']), fn ($q) => $q->where('kecamatan_id', $filters['kecamatan_id']))
|
||||
->when(! empty($filters['desa_id']), fn ($q) => $q->where('desa_id', $filters['desa_id']));
|
||||
if (! empty($filters['topology_node_id'])) {
|
||||
$nodeIds = $this->downstreamNodeIds((int) $filters['topology_node_id'], $tenantId);
|
||||
$query->whereHas('activeTopologyConnection', fn ($q) => $q->whereIn('odp_node_id', $nodeIds));
|
||||
}
|
||||
|
||||
return $query;
|
||||
}
|
||||
|
||||
private function downstreamNodeIds(int $rootId, int $tenantId): array
|
||||
{
|
||||
$ids = [$rootId];
|
||||
$frontier = [$rootId];
|
||||
while ($frontier && count($ids) < 10000) {
|
||||
$frontier = TopologyLink::where('tenant_id', $tenantId)->whereIn('source_node_id', $frontier)
|
||||
->pluck('target_node_id')->diff($ids)->values()->all();
|
||||
$ids = [...$ids, ...$frontier];
|
||||
}
|
||||
|
||||
return array_values(array_unique($ids));
|
||||
}
|
||||
|
||||
private function extractVariables(string $value): array
|
||||
{
|
||||
preg_match_all('/{{\s*([a-zA-Z0-9_.]+)\s*}}/', $value, $matches);
|
||||
|
||||
return array_values(array_unique($matches[1]));
|
||||
}
|
||||
|
||||
private function dispatchBroadcast(NotificationBroadcast $broadcast): void
|
||||
{
|
||||
ProcessBroadcast::dispatch($broadcast->id)
|
||||
->delay($broadcast->scheduled_at)
|
||||
->onQueue('notifications-broadcast')
|
||||
->afterCommit();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,138 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services\Payment;
|
||||
|
||||
use App\Models\PaymentGatewayAccount;
|
||||
use App\Models\PaymentProvider;
|
||||
use App\Models\PaymentTransaction;
|
||||
use App\Models\TenantPaymentSetting;
|
||||
use App\Models\User;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Str;
|
||||
use Illuminate\Validation\ValidationException;
|
||||
|
||||
class PaymentGatewayService
|
||||
{
|
||||
public function providers()
|
||||
{
|
||||
return PaymentProvider::where('enabled', true)->orderBy('name')->get();
|
||||
}
|
||||
|
||||
public function settings(User $actor, ?int $tenantId): array
|
||||
{
|
||||
$accounts = PaymentGatewayAccount::with('provider:id,code,name,scope,capabilities,credential_schema')
|
||||
->when(! $actor->isMasterAdmin(), fn ($q) => $q->where(fn ($x) => $x->where('tenant_id', $tenantId)->orWhere('scope', 'platform')))
|
||||
->when($actor->isMasterAdmin() && $tenantId, fn ($q) => $q->where(fn ($x) => $x->where('tenant_id', $tenantId)->orWhere('scope', 'platform')))
|
||||
->orderBy('scope')->orderBy('name')->get()
|
||||
->map(fn ($account) => $this->maskedAccount($account));
|
||||
|
||||
$setting = $tenantId ? TenantPaymentSetting::with(['tenantGateway.provider', 'platformGateway.provider'])->firstOrCreate(
|
||||
['tenant_id' => $tenantId],
|
||||
['payment_mode' => 'disabled', 'fee_bearer' => 'customer', 'default_expiry_minutes' => 1440]
|
||||
) : null;
|
||||
|
||||
return compact('accounts', 'setting');
|
||||
}
|
||||
|
||||
public function saveAccount(array $data, User $actor, ?int $tenantId, ?PaymentGatewayAccount $account = null): PaymentGatewayAccount
|
||||
{
|
||||
$scope = $data['scope'];
|
||||
if ($scope === 'platform' && ! $actor->isMasterAdmin()) {
|
||||
abort(403, 'Hanya Master Admin yang dapat mengelola gateway platform.');
|
||||
}
|
||||
$effectiveTenant = $scope === 'platform' ? null : ($actor->isMasterAdmin() ? ($data['tenant_id'] ?? $tenantId) : $tenantId);
|
||||
if ($scope === 'tenant' && ! $effectiveTenant) {
|
||||
throw ValidationException::withMessages(['tenant_id' => 'Tenant wajib dipilih.']);
|
||||
}
|
||||
if ($account && ($account->scope !== $scope || ($scope === 'tenant' && (int) $account->tenant_id !== (int) $effectiveTenant))) {
|
||||
abort(403);
|
||||
}
|
||||
|
||||
return DB::transaction(function () use ($data, $account, $scope, $effectiveTenant) {
|
||||
if (($data['enabled'] ?? false) && $scope === 'tenant') {
|
||||
PaymentGatewayAccount::where('tenant_id', $effectiveTenant)->where('scope', 'tenant')
|
||||
->when($account, fn ($q) => $q->whereKeyNot($account->id))->update(['enabled' => false, 'is_default' => false]);
|
||||
}
|
||||
if (($data['is_default'] ?? false) && $scope === 'platform') {
|
||||
PaymentGatewayAccount::where('scope', 'platform')
|
||||
->when($account, fn ($q) => $q->whereKeyNot($account->id))->update(['is_default' => false]);
|
||||
}
|
||||
|
||||
$payload = [
|
||||
'uuid' => $account?->uuid ?? Str::uuid(),
|
||||
'payment_provider_id' => $data['payment_provider_id'],
|
||||
'tenant_id' => $effectiveTenant,
|
||||
'scope' => $scope,
|
||||
'name' => $data['name'],
|
||||
'environment' => $data['environment'],
|
||||
'settings' => $data['settings'] ?? [],
|
||||
'enabled' => $data['enabled'] ?? false,
|
||||
'is_default' => $data['is_default'] ?? false,
|
||||
];
|
||||
if (! empty($data['credentials'])) {
|
||||
$payload['credentials'] = $data['credentials'];
|
||||
}
|
||||
if (filled($data['webhook_secret'] ?? null)) {
|
||||
$payload['webhook_secret'] = $data['webhook_secret'];
|
||||
}
|
||||
|
||||
$model = $account ?: new PaymentGatewayAccount;
|
||||
$model->fill($payload)->save();
|
||||
|
||||
return $model->load('provider');
|
||||
});
|
||||
}
|
||||
|
||||
public function saveTenantSetting(array $data, User $actor, ?int $tenantId): TenantPaymentSetting
|
||||
{
|
||||
$effectiveTenant = $actor->isMasterAdmin() ? ($data['tenant_id'] ?? $tenantId) : $tenantId;
|
||||
if (! $effectiveTenant) {
|
||||
throw ValidationException::withMessages(['tenant_id' => 'Tenant wajib dipilih.']);
|
||||
}
|
||||
|
||||
if ($data['payment_mode'] === 'tenant_gateway') {
|
||||
$valid = PaymentGatewayAccount::whereKey($data['tenant_gateway_account_id'] ?? 0)
|
||||
->where('tenant_id', $effectiveTenant)->where('scope', 'tenant')->where('enabled', true)->exists();
|
||||
if (! $valid) {
|
||||
throw ValidationException::withMessages(['tenant_gateway_account_id' => 'Gateway tenant aktif tidak valid.']);
|
||||
}
|
||||
}
|
||||
if ($data['payment_mode'] === 'platform_gateway') {
|
||||
$valid = PaymentGatewayAccount::whereKey($data['platform_gateway_account_id'] ?? 0)
|
||||
->whereNull('tenant_id')->where('scope', 'platform')->where('enabled', true)->exists();
|
||||
if (! $valid) {
|
||||
throw ValidationException::withMessages(['platform_gateway_account_id' => 'Gateway platform aktif tidak valid.']);
|
||||
}
|
||||
}
|
||||
|
||||
return TenantPaymentSetting::updateOrCreate(['tenant_id' => $effectiveTenant], [
|
||||
...$data,
|
||||
'tenant_id' => $effectiveTenant,
|
||||
'tenant_gateway_account_id' => $data['payment_mode'] === 'tenant_gateway' ? $data['tenant_gateway_account_id'] : null,
|
||||
'platform_gateway_account_id' => $data['payment_mode'] === 'platform_gateway' ? $data['platform_gateway_account_id'] : null,
|
||||
])->load(['tenantGateway.provider', 'platformGateway.provider']);
|
||||
}
|
||||
|
||||
public function transactions(array $filters, User $actor, ?int $tenantId)
|
||||
{
|
||||
return PaymentTransaction::with(['tenant:id,tenant_name', 'user:id,name', 'customer:id,name,customer_code', 'invoice:id,invoice_number', 'account.provider:id,code,name'])
|
||||
->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('transaction_number', 'ilike', "%{$search}%")->orWhere('provider_reference', 'ilike', "%{$search}%")))
|
||||
->when($filters['status'] ?? null, fn ($q, $value) => $q->where('status', $value))
|
||||
->when($filters['provider_code'] ?? null, fn ($q, $value) => $q->where('provider_code', $value))
|
||||
->when($filters['purpose'] ?? null, fn ($q, $value) => $q->where('purpose', $value))
|
||||
->when($filters['date_from'] ?? null, fn ($q, $value) => $q->whereDate('created_at', '>=', $value))
|
||||
->when($filters['date_to'] ?? null, fn ($q, $value) => $q->whereDate('created_at', '<=', $value))
|
||||
->orderBy($filters['sort_by'] ?? 'id', $filters['sort_direction'] ?? 'desc')
|
||||
->paginate($filters['per_page'] ?? 10);
|
||||
}
|
||||
|
||||
private function maskedAccount(PaymentGatewayAccount $account): PaymentGatewayAccount
|
||||
{
|
||||
$account->setAttribute('has_credentials', ! empty($account->getRawOriginal('credentials')));
|
||||
$account->setAttribute('has_webhook_secret', ! empty($account->getRawOriginal('webhook_secret')));
|
||||
|
||||
return $account;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,421 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services\Topology;
|
||||
|
||||
use App\Models\Customer;
|
||||
use App\Models\TopologyConnectionRule;
|
||||
use App\Models\TopologyCustomerConnection;
|
||||
use App\Models\TopologyDeviceType;
|
||||
use App\Models\TopologyLink;
|
||||
use App\Models\TopologyNode;
|
||||
use App\Models\TopologyPort;
|
||||
use App\Models\User;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Support\Collection;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Validation\ValidationException;
|
||||
|
||||
class TopologyService
|
||||
{
|
||||
private const RELATIONS = [
|
||||
'device-type' => [],
|
||||
'node' => ['tenant:id,tenant_code,tenant_name', 'deviceType:id,tenant_id,code,name,category,color,icon', 'parent:id,code,name'],
|
||||
'link' => ['tenant:id,tenant_code,tenant_name', 'sourceNode.deviceType:id,code,name,color', 'targetNode.deviceType:id,code,name,color', 'sourcePort:id,node_id,name,port_number', 'targetPort:id,node_id,name,port_number'],
|
||||
];
|
||||
|
||||
public function list(string $resource, array $filters, User $actor, ?int $tenantId)
|
||||
{
|
||||
$query = $this->model($resource)::query()->with(self::RELATIONS[$resource]);
|
||||
$this->scopeTenant($query, $resource, $actor, $tenantId);
|
||||
$sortable = [
|
||||
'device-type' => ['id', 'code', 'name', 'category', 'status', 'created_at'],
|
||||
'node' => ['id', 'code', 'name', 'status', 'installed_at', 'created_at'],
|
||||
'link' => ['id', 'code', 'name', 'link_type', 'status', 'installed_at', 'created_at'],
|
||||
][$resource];
|
||||
$sortBy = in_array($filters['sort_by'] ?? null, $sortable, true) ? $filters['sort_by'] : 'id';
|
||||
|
||||
return $query
|
||||
->when(! empty($filters['status']), fn ($q) => $q->where('status', $filters['status']))
|
||||
->when(! empty($filters['device_type_id']) && $resource === 'node', fn ($q) => $q->where('device_type_id', $filters['device_type_id']))
|
||||
->when(! empty($filters['link_type']) && $resource === 'link', fn ($q) => $q->where('link_type', $filters['link_type']))
|
||||
->when(! empty($filters['search']), fn ($q) => $q->where(function ($search) use ($filters) {
|
||||
$term = "%{$filters['search']}%";
|
||||
$search->where('code', 'ILIKE', $term)->orWhere('name', 'ILIKE', $term);
|
||||
}))
|
||||
->orderBy($sortBy, $filters['sort_direction'] ?? 'desc')
|
||||
->paginate($filters['per_page'] ?? 10);
|
||||
}
|
||||
|
||||
public function find(string $resource, int $id): Model
|
||||
{
|
||||
$relations = self::RELATIONS[$resource];
|
||||
if ($resource === 'node') {
|
||||
$relations = [...$relations, 'ports'];
|
||||
}
|
||||
|
||||
return $this->model($resource)::with($relations)->findOrFail($id);
|
||||
}
|
||||
|
||||
public function create(string $resource, array $data, User $actor, ?int $tenantId): Model
|
||||
{
|
||||
return DB::transaction(function () use ($resource, $data, $actor, $tenantId) {
|
||||
$data = $this->prepare($resource, $data, $actor, $tenantId);
|
||||
$model = $this->model($resource)::create($data);
|
||||
|
||||
return $this->find($resource, $model->id);
|
||||
});
|
||||
}
|
||||
|
||||
public function update(string $resource, Model $model, array $data, User $actor): Model
|
||||
{
|
||||
return DB::transaction(function () use ($resource, $model, $data, $actor) {
|
||||
if (! $actor->isMasterAdmin()) {
|
||||
unset($data['tenant_id']);
|
||||
}
|
||||
$data['tenant_id'] ??= $model->tenant_id;
|
||||
$data = $this->prepare($resource, $data, $actor, $model->tenant_id, $model);
|
||||
$model->update($data);
|
||||
|
||||
return $this->find($resource, $model->id);
|
||||
});
|
||||
}
|
||||
|
||||
public function delete(string $resource, Model $model): void
|
||||
{
|
||||
if ($resource === 'device-type' && ($model->is_system || $model->nodes()->exists())) {
|
||||
throw ValidationException::withMessages(['device_type' => 'Kategori sistem atau kategori yang digunakan tidak dapat dihapus.']);
|
||||
}
|
||||
if ($resource === 'node' && ($model->incomingLinks()->exists() || $model->outgoingLinks()->exists())) {
|
||||
throw ValidationException::withMessages(['node' => 'Perangkat masih terhubung dengan jalur jaringan.']);
|
||||
}
|
||||
if ($resource === 'node' && TopologyCustomerConnection::where('odp_node_id', $model->id)->where('status', 'active')->exists()) {
|
||||
throw ValidationException::withMessages(['node' => 'Perangkat masih melayani customer aktif.']);
|
||||
}
|
||||
$model->delete();
|
||||
}
|
||||
|
||||
public function options(User $actor, ?int $tenantId): array
|
||||
{
|
||||
$types = TopologyDeviceType::query();
|
||||
$nodes = TopologyNode::query()->with('deviceType:id,code,name,color');
|
||||
$this->scopeTenant($types, 'device-type', $actor, $tenantId);
|
||||
$this->scopeTenant($nodes, 'node', $actor, $tenantId);
|
||||
|
||||
return [
|
||||
'device_types' => $types->where('status', 'active')->orderBy('name')->get(),
|
||||
'nodes' => $nodes->where('status', 'active')->orderBy('name')->get(),
|
||||
];
|
||||
}
|
||||
|
||||
public function ports(TopologyNode $node): Collection
|
||||
{
|
||||
return $node->ports()->get();
|
||||
}
|
||||
|
||||
public function createPort(TopologyNode $node, array $data): TopologyPort
|
||||
{
|
||||
if (! $node->deviceType->can_have_ports) {
|
||||
throw ValidationException::withMessages(['node_id' => 'Kategori perangkat ini tidak mendukung port.']);
|
||||
}
|
||||
|
||||
return $node->ports()->create([...$data, 'tenant_id' => $node->tenant_id]);
|
||||
}
|
||||
|
||||
public function updatePort(TopologyPort $port, array $data): TopologyPort
|
||||
{
|
||||
$port->update($data);
|
||||
|
||||
return $port->fresh();
|
||||
}
|
||||
|
||||
public function deletePort(TopologyPort $port): void
|
||||
{
|
||||
if (TopologyLink::where('source_port_id', $port->id)->orWhere('target_port_id', $port->id)->exists()) {
|
||||
throw ValidationException::withMessages(['port' => 'Port masih digunakan oleh jalur jaringan.']);
|
||||
}
|
||||
$port->delete();
|
||||
}
|
||||
|
||||
public function map(array $filters, User $actor, ?int $tenantId): array
|
||||
{
|
||||
$nodes = TopologyNode::query()->with('deviceType:id,code,name,color,icon');
|
||||
$links = TopologyLink::query()->with(['sourceNode:id,latitude,longitude', 'targetNode:id,latitude,longitude']);
|
||||
$this->scopeTenant($nodes, 'node', $actor, $tenantId);
|
||||
$this->scopeTenant($links, 'link', $actor, $tenantId);
|
||||
|
||||
if (! empty($filters['bbox'])) {
|
||||
[$minLng, $minLat, $maxLng, $maxLat] = array_map('floatval', explode(',', $filters['bbox']));
|
||||
$nodes->whereBetween('longitude', [$minLng, $maxLng])->whereBetween('latitude', [$minLat, $maxLat]);
|
||||
$links->where(function ($query) use ($minLng, $minLat, $maxLng, $maxLat) {
|
||||
$query->whereHas('sourceNode', fn ($q) => $q->whereBetween('longitude', [$minLng, $maxLng])->whereBetween('latitude', [$minLat, $maxLat]))
|
||||
->orWhereHas('targetNode', fn ($q) => $q->whereBetween('longitude', [$minLng, $maxLng])->whereBetween('latitude', [$minLat, $maxLat]));
|
||||
});
|
||||
}
|
||||
|
||||
$nodeRows = $nodes->where('status', '!=', 'inactive')->get();
|
||||
$linkRows = $links->where('status', '!=', 'inactive')->get();
|
||||
|
||||
return [
|
||||
'nodes' => [
|
||||
'type' => 'FeatureCollection',
|
||||
'features' => $nodeRows->map(fn ($node) => [
|
||||
'type' => 'Feature',
|
||||
'id' => $node->id,
|
||||
'geometry' => ['type' => 'Point', 'coordinates' => [(float) $node->longitude, (float) $node->latitude]],
|
||||
'properties' => [
|
||||
'id' => $node->id,
|
||||
'code' => $node->code,
|
||||
'name' => $node->name,
|
||||
'status' => $node->status,
|
||||
'device_type' => $node->deviceType?->code,
|
||||
'color' => $node->deviceType?->color ?? '#2f74c0',
|
||||
],
|
||||
])->values(),
|
||||
],
|
||||
'links' => [
|
||||
'type' => 'FeatureCollection',
|
||||
'features' => $linkRows->map(function ($link) {
|
||||
$geometry = $link->path_geojson ?: [
|
||||
'type' => 'LineString',
|
||||
'coordinates' => [
|
||||
[(float) $link->sourceNode->longitude, (float) $link->sourceNode->latitude],
|
||||
[(float) $link->targetNode->longitude, (float) $link->targetNode->latitude],
|
||||
],
|
||||
];
|
||||
|
||||
return [
|
||||
'type' => 'Feature',
|
||||
'id' => $link->id,
|
||||
'geometry' => $geometry,
|
||||
'properties' => [
|
||||
'id' => $link->id,
|
||||
'code' => $link->code,
|
||||
'name' => $link->name,
|
||||
'status' => $link->status,
|
||||
'link_type' => $link->link_type,
|
||||
'core_count' => $link->core_count,
|
||||
],
|
||||
];
|
||||
})->values(),
|
||||
],
|
||||
];
|
||||
}
|
||||
|
||||
public function trace(TopologyNode $node, string $direction): array
|
||||
{
|
||||
$visited = [];
|
||||
$levels = [[$node->id]];
|
||||
while ($levels[count($levels) - 1] && count($levels) <= 100) {
|
||||
$ids = $levels[count($levels) - 1];
|
||||
$visited = [...$visited, ...$ids];
|
||||
$column = $direction === 'upstream' ? 'target_node_id' : 'source_node_id';
|
||||
$select = $direction === 'upstream' ? 'source_node_id' : 'target_node_id';
|
||||
$next = TopologyLink::where('tenant_id', $node->tenant_id)
|
||||
->whereIn($column, $ids)
|
||||
->where('status', 'active')
|
||||
->pluck($select)
|
||||
->reject(fn ($id) => in_array($id, $visited))
|
||||
->unique()
|
||||
->values()
|
||||
->all();
|
||||
if (! $next) {
|
||||
break;
|
||||
}
|
||||
$levels[] = $next;
|
||||
}
|
||||
|
||||
$nodes = TopologyNode::with('deviceType:id,code,name,color')
|
||||
->whereIn('id', collect($levels)->flatten()->unique())
|
||||
->get()->keyBy('id');
|
||||
|
||||
return collect($levels)->map(fn ($ids, $level) => [
|
||||
'level' => $level,
|
||||
'nodes' => collect($ids)->map(fn ($id) => $nodes->get($id))->filter()->values(),
|
||||
])->all();
|
||||
}
|
||||
|
||||
public function connectCustomer(array $data, User $actor, ?int $tenantId): TopologyCustomerConnection
|
||||
{
|
||||
$customer = Customer::findOrFail($data['customer_id']);
|
||||
$node = TopologyNode::with('deviceType')->findOrFail($data['odp_node_id']);
|
||||
$effectiveTenant = $actor->isMasterAdmin() ? $customer->tenant_id : $tenantId;
|
||||
if ((int) $customer->tenant_id !== (int) $effectiveTenant || (int) $node->tenant_id !== (int) $effectiveTenant || $node->deviceType?->code !== 'ODP') {
|
||||
throw ValidationException::withMessages(['odp_node_id' => 'Customer dan ODP harus berasal dari tenant yang sama dan perangkat harus bertipe ODP.']);
|
||||
}
|
||||
if (! empty($data['odp_port_id']) && ! TopologyPort::whereKey($data['odp_port_id'])->where('node_id', $node->id)->exists()) {
|
||||
throw ValidationException::withMessages(['odp_port_id' => 'Port bukan milik ODP yang dipilih.']);
|
||||
}
|
||||
|
||||
TopologyCustomerConnection::where('customer_id', $customer->id)->where('status', 'active')
|
||||
->update(['status' => 'inactive', 'disconnected_at' => now()]);
|
||||
$customer->update(['topology_id' => $node->id]);
|
||||
|
||||
return TopologyCustomerConnection::create([
|
||||
...$data,
|
||||
'tenant_id' => $effectiveTenant,
|
||||
'connected_at' => $data['connected_at'] ?? now(),
|
||||
'status' => 'active',
|
||||
])->load(['customer:id,customer_code,name', 'odpNode:id,code,name', 'odpPort:id,name,port_number']);
|
||||
}
|
||||
|
||||
public function customerPath(Customer $customer): array
|
||||
{
|
||||
$connection = TopologyCustomerConnection::with(['odpNode.deviceType', 'odpPort'])
|
||||
->where('customer_id', $customer->id)
|
||||
->where('status', 'active')
|
||||
->first();
|
||||
if (! $connection) {
|
||||
throw ValidationException::withMessages(['customer_id' => 'Customer belum terhubung ke ODP.']);
|
||||
}
|
||||
|
||||
return [
|
||||
'customer' => $customer->only(['id', 'customer_code', 'name']),
|
||||
'connection' => $connection,
|
||||
'path' => $this->trace($connection->odpNode, 'upstream'),
|
||||
];
|
||||
}
|
||||
|
||||
private function prepare(string $resource, array $data, User $actor, ?int $tenantId, ?Model $existing = null): array
|
||||
{
|
||||
if ($resource === 'device-type') {
|
||||
$data['tenant_id'] = $actor->isMasterAdmin() ? ($data['tenant_id'] ?? null) : $tenantId;
|
||||
$data['is_system'] = $existing?->is_system ?? false;
|
||||
|
||||
return $data;
|
||||
}
|
||||
|
||||
$data['tenant_id'] = $actor->isMasterAdmin() ? ($data['tenant_id'] ?? $tenantId) : $tenantId;
|
||||
if (! $data['tenant_id']) {
|
||||
throw ValidationException::withMessages(['tenant_id' => 'Tenant wajib dipilih.']);
|
||||
}
|
||||
if ($resource === 'node') {
|
||||
$type = TopologyDeviceType::whereKey($data['device_type_id'] ?? $existing?->device_type_id)
|
||||
->where(fn ($q) => $q->whereNull('tenant_id')->orWhere('tenant_id', $data['tenant_id']))
|
||||
->first();
|
||||
if (! $type) {
|
||||
throw ValidationException::withMessages(['device_type_id' => 'Kategori perangkat tidak tersedia untuk tenant ini.']);
|
||||
}
|
||||
if (! empty($data['parent_node_id']) && ! TopologyNode::whereKey($data['parent_node_id'])->where('tenant_id', $data['tenant_id'])->exists()) {
|
||||
throw ValidationException::withMessages(['parent_node_id' => 'Parent perangkat bukan milik tenant yang sama.']);
|
||||
}
|
||||
}
|
||||
if ($resource === 'link') {
|
||||
$this->validateLink($data, $existing);
|
||||
}
|
||||
$data['updated_by'] = $actor->id;
|
||||
if (! $existing) {
|
||||
$data['created_by'] = $actor->id;
|
||||
}
|
||||
|
||||
return $data;
|
||||
}
|
||||
|
||||
private function validateLink(array $data, ?Model $existing): void
|
||||
{
|
||||
$sourceId = (int) ($data['source_node_id'] ?? $existing?->source_node_id);
|
||||
$targetId = (int) ($data['target_node_id'] ?? $existing?->target_node_id);
|
||||
$tenantId = (int) $data['tenant_id'];
|
||||
$nodes = TopologyNode::with('deviceType')->whereIn('id', [$sourceId, $targetId])->where('tenant_id', $tenantId)->get()->keyBy('id');
|
||||
if ($sourceId === $targetId || $nodes->count() !== 2) {
|
||||
throw ValidationException::withMessages(['target_node_id' => 'Node asal dan tujuan harus berbeda dan berasal dari tenant yang sama.']);
|
||||
}
|
||||
$rule = TopologyConnectionRule::where(fn ($q) => $q->whereNull('tenant_id')->orWhere('tenant_id', $tenantId))
|
||||
->where('source_device_type_id', $nodes[$sourceId]->device_type_id)
|
||||
->where('target_device_type_id', $nodes[$targetId]->device_type_id)
|
||||
->orderByDesc('tenant_id')
|
||||
->first();
|
||||
if ($rule && ! $rule->allowed) {
|
||||
throw ValidationException::withMessages(['target_node_id' => 'Hubungan antar kategori perangkat ini tidak diizinkan.']);
|
||||
}
|
||||
if ($rule?->max_connections) {
|
||||
$connectionCount = TopologyLink::where('source_node_id', $sourceId)
|
||||
->when($existing, fn ($q) => $q->whereKeyNot($existing->id))
|
||||
->count();
|
||||
if ($connectionCount >= $rule->max_connections) {
|
||||
throw ValidationException::withMessages(['source_node_id' => 'Batas maksimal koneksi perangkat asal sudah tercapai.']);
|
||||
}
|
||||
}
|
||||
$duplicate = TopologyLink::where('source_node_id', $sourceId)
|
||||
->where('target_node_id', $targetId)
|
||||
->when($existing, fn ($q) => $q->whereKeyNot($existing->id))
|
||||
->where(function ($query) use ($data, $existing) {
|
||||
$sourcePortId = $data['source_port_id'] ?? $existing?->source_port_id;
|
||||
$targetPortId = $data['target_port_id'] ?? $existing?->target_port_id;
|
||||
$sourcePortId ? $query->where('source_port_id', $sourcePortId) : $query->whereNull('source_port_id');
|
||||
$targetPortId ? $query->where('target_port_id', $targetPortId) : $query->whereNull('target_port_id');
|
||||
})
|
||||
->exists();
|
||||
if ($duplicate) {
|
||||
throw ValidationException::withMessages(['target_node_id' => 'Jalur dengan endpoint yang sama sudah tersedia.']);
|
||||
}
|
||||
foreach (['source_port_id' => $sourceId, 'target_port_id' => $targetId] as $field => $nodeId) {
|
||||
$portId = $data[$field] ?? $existing?->{$field};
|
||||
if ($portId) {
|
||||
$port = TopologyPort::whereKey($portId)->where('node_id', $nodeId)->where('tenant_id', $tenantId)->first();
|
||||
if (! $port) {
|
||||
throw ValidationException::withMessages([$field => 'Port tidak sesuai dengan node yang dipilih.']);
|
||||
}
|
||||
$used = TopologyLink::where(fn ($q) => $q->where('source_port_id', $portId)->orWhere('target_port_id', $portId))
|
||||
->when($existing, fn ($q) => $q->whereKeyNot($existing->id))
|
||||
->count();
|
||||
if ($used >= $port->capacity) {
|
||||
throw ValidationException::withMessages([$field => 'Kapasitas port sudah terpakai penuh.']);
|
||||
}
|
||||
}
|
||||
}
|
||||
if ($this->hasDirectedPath($targetId, $sourceId, $tenantId, $existing?->id)) {
|
||||
throw ValidationException::withMessages(['target_node_id' => 'Jalur ini membentuk loop topologi.']);
|
||||
}
|
||||
}
|
||||
|
||||
private function hasDirectedPath(int $from, int $to, int $tenantId, ?int $ignoreLink): bool
|
||||
{
|
||||
$queue = [$from];
|
||||
$visited = [];
|
||||
while ($queue) {
|
||||
$current = array_shift($queue);
|
||||
if ($current === $to) {
|
||||
return true;
|
||||
}
|
||||
if (isset($visited[$current])) {
|
||||
continue;
|
||||
}
|
||||
$visited[$current] = true;
|
||||
$next = TopologyLink::where('tenant_id', $tenantId)
|
||||
->where('source_node_id', $current)
|
||||
->when($ignoreLink, fn ($q) => $q->whereKeyNot($ignoreLink))
|
||||
->pluck('target_node_id')
|
||||
->all();
|
||||
$queue = [...$queue, ...$next];
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
private function scopeTenant($query, string $resource, User $actor, ?int $tenantId): void
|
||||
{
|
||||
if ($resource === 'device-type') {
|
||||
if ($actor->isMasterAdmin() && ! $tenantId) {
|
||||
return;
|
||||
}
|
||||
$query->where(function ($q) use ($tenantId) {
|
||||
$q->whereNull('tenant_id')
|
||||
->orWhere('tenant_id', $tenantId);
|
||||
});
|
||||
|
||||
return;
|
||||
}
|
||||
$query->when(! $actor->isMasterAdmin(), fn ($q) => $q->where('tenant_id', $tenantId))
|
||||
->when($actor->isMasterAdmin() && $tenantId, fn ($q) => $q->where('tenant_id', $tenantId));
|
||||
}
|
||||
|
||||
private function model(string $resource): string
|
||||
{
|
||||
return match ($resource) {
|
||||
'device-type' => TopologyDeviceType::class,
|
||||
'node' => TopologyNode::class,
|
||||
'link' => TopologyLink::class,
|
||||
default => throw ValidationException::withMessages(['resource' => 'Resource topologi tidak valid.']),
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,123 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services\Wallet;
|
||||
|
||||
use App\Models\Tenant;
|
||||
use App\Models\User;
|
||||
use App\Models\Wallet;
|
||||
use App\Models\WalletLedgerEntry;
|
||||
use App\Models\WalletTransaction;
|
||||
use App\Models\WalletWithdrawal;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Str;
|
||||
use Illuminate\Validation\ValidationException;
|
||||
|
||||
class WalletService
|
||||
{
|
||||
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'));
|
||||
}
|
||||
|
||||
return $wallets->values();
|
||||
}
|
||||
|
||||
public function ledger(Wallet $wallet, User $actor, ?int $tenantId, array $filters)
|
||||
{
|
||||
$this->authorizeWallet($wallet, $actor, $tenantId);
|
||||
|
||||
return $wallet->ledgerEntries()->with('transaction:id,uuid,transaction_number,type,status,description,created_at')
|
||||
->latest('id')->paginate($filters['per_page'] ?? 10);
|
||||
}
|
||||
|
||||
public function transfer(Wallet $source, Wallet $destination, float $amount, User $actor, ?int $tenantId): WalletTransaction
|
||||
{
|
||||
$this->authorizeWallet($source, $actor, $tenantId);
|
||||
$this->authorizeWallet($destination, $actor, $tenantId);
|
||||
if ($source->is($destination)) {
|
||||
throw ValidationException::withMessages(['destination_wallet_id' => 'Dompet tujuan harus berbeda.']);
|
||||
}
|
||||
if ($amount <= 0) {
|
||||
throw ValidationException::withMessages(['amount' => 'Nominal harus lebih dari nol.']);
|
||||
}
|
||||
|
||||
return DB::transaction(function () use ($source, $destination, $amount, $actor) {
|
||||
$wallets = Wallet::whereIn('id', [$source->id, $destination->id])->lockForUpdate()->get()->keyBy('id');
|
||||
$from = $wallets[$source->id];
|
||||
$to = $wallets[$destination->id];
|
||||
if ((float) $from->available_balance < $amount) {
|
||||
throw ValidationException::withMessages(['amount' => 'Saldo tidak mencukupi.']);
|
||||
}
|
||||
$transaction = $this->newTransaction('transfer', $amount, $actor, 'Pindah saldo antar dompet');
|
||||
$this->post($from, $transaction, 'debit', $amount);
|
||||
$this->post($to, $transaction, 'credit', $amount);
|
||||
$transaction->update(['status' => 'completed', 'completed_at' => now()]);
|
||||
|
||||
return $transaction->load('entries.wallet');
|
||||
});
|
||||
}
|
||||
|
||||
public function requestWithdrawal(Wallet $wallet, array $data, User $actor, ?int $tenantId): WalletWithdrawal
|
||||
{
|
||||
$this->authorizeWallet($wallet, $actor, $tenantId);
|
||||
|
||||
return DB::transaction(function () use ($wallet, $data, $actor) {
|
||||
$locked = Wallet::lockForUpdate()->findOrFail($wallet->id);
|
||||
$total = (float) $data['amount'] + (float) ($data['fee'] ?? 0);
|
||||
if ((float) $locked->available_balance < $total) {
|
||||
throw ValidationException::withMessages(['amount' => 'Saldo tidak mencukupi.']);
|
||||
}
|
||||
$transaction = $this->newTransaction('withdrawal', $data['amount'], $actor, 'Permintaan penarikan saldo', $data['fee'] ?? 0);
|
||||
$this->post($locked, $transaction, 'debit', $total);
|
||||
|
||||
return WalletWithdrawal::create([
|
||||
'uuid' => Str::uuid(), 'wallet_id' => $locked->id, 'wallet_transaction_id' => $transaction->id,
|
||||
'requested_by' => $actor->id, 'amount' => $data['amount'], 'fee' => $data['fee'] ?? 0,
|
||||
'bank_code' => $data['bank_code'], 'account_number' => $data['account_number'],
|
||||
'account_name' => $data['account_name'], 'status' => 'pending_approval',
|
||||
]);
|
||||
});
|
||||
}
|
||||
|
||||
private function walletFor(Model $owner, string $type): Wallet
|
||||
{
|
||||
return Wallet::firstOrCreate(
|
||||
['owner_type' => $owner->getMorphClass(), 'owner_id' => $owner->getKey(), 'wallet_type' => $type, 'currency' => 'IDR'],
|
||||
['uuid' => Str::uuid(), 'status' => 'active']
|
||||
);
|
||||
}
|
||||
|
||||
private function newTransaction(string $type, float $amount, User $actor, string $description, float $fee = 0): WalletTransaction
|
||||
{
|
||||
return WalletTransaction::create([
|
||||
'uuid' => Str::uuid(), 'transaction_number' => 'WLT-'.now()->format('YmdHis').'-'.Str::upper(Str::random(6)),
|
||||
'type' => $type, 'status' => 'processing', 'initiated_by' => $actor->id, 'amount' => $amount,
|
||||
'fee' => $fee, 'idempotency_key' => (string) Str::uuid(), 'description' => $description,
|
||||
]);
|
||||
}
|
||||
|
||||
private function post(Wallet $wallet, WalletTransaction $transaction, string $type, float $amount): void
|
||||
{
|
||||
$before = (float) $wallet->available_balance;
|
||||
$after = $type === 'credit' ? $before + $amount : $before - $amount;
|
||||
WalletLedgerEntry::create([
|
||||
'wallet_id' => $wallet->id, 'wallet_transaction_id' => $transaction->id, 'entry_type' => $type,
|
||||
'balance_bucket' => 'available', 'amount' => $amount, 'balance_before' => $before,
|
||||
'balance_after' => $after, 'description' => $transaction->description,
|
||||
]);
|
||||
$wallet->update(['available_balance' => $after, 'version' => $wallet->version + 1]);
|
||||
}
|
||||
|
||||
private function authorizeWallet(Wallet $wallet, User $actor, ?int $tenantId): void
|
||||
{
|
||||
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.');
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user