diff --git a/.env.example b/.env.example index a8b21b9..7f39047 100644 --- a/.env.example +++ b/.env.example @@ -3,6 +3,7 @@ APP_ENV=local APP_KEY= APP_DEBUG=true APP_URL=http://localhost +APP_TIMEZONE=Asia/Jakarta APP_LOCALE=en APP_FALLBACK_LOCALE=en diff --git a/app/Console/Commands/ProcessBillingSchedules.php b/app/Console/Commands/ProcessBillingSchedules.php new file mode 100644 index 0000000..fa879fa --- /dev/null +++ b/app/Console/Commands/ProcessBillingSchedules.php @@ -0,0 +1,24 @@ +processSchedules(); + + $this->info( + "Scheduler tagihan selesai: {$result['created']} dibuat, {$result['updated']} status diperbarui.", + ); + + return self::SUCCESS; + } +} diff --git a/app/Console/Commands/PruneNotifications.php b/app/Console/Commands/PruneNotifications.php new file mode 100644 index 0000000..b32957e --- /dev/null +++ b/app/Console/Commands/PruneNotifications.php @@ -0,0 +1,31 @@ +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; + } +} diff --git a/app/Http/Controllers/Billing/BillingProfileController.php b/app/Http/Controllers/Billing/BillingProfileController.php new file mode 100644 index 0000000..f9659ab --- /dev/null +++ b/app/Http/Controllers/Billing/BillingProfileController.php @@ -0,0 +1,61 @@ +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); + } +} diff --git a/app/Http/Controllers/Billing/InvoiceController.php b/app/Http/Controllers/Billing/InvoiceController.php new file mode 100644 index 0000000..ee84b51 --- /dev/null +++ b/app/Http/Controllers/Billing/InvoiceController.php @@ -0,0 +1,79 @@ +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); + } +} diff --git a/app/Http/Controllers/Customer/CustomerController.php b/app/Http/Controllers/Customer/CustomerController.php new file mode 100644 index 0000000..daf02fa --- /dev/null +++ b/app/Http/Controllers/Customer/CustomerController.php @@ -0,0 +1,118 @@ +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); + } +} diff --git a/app/Http/Controllers/Notification/NotificationController.php b/app/Http/Controllers/Notification/NotificationController.php new file mode 100644 index 0000000..9a57991 --- /dev/null +++ b/app/Http/Controllers/Notification/NotificationController.php @@ -0,0 +1,192 @@ +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); + } +} diff --git a/app/Http/Controllers/Notification/NotificationWebhookController.php b/app/Http/Controllers/Notification/NotificationWebhookController.php new file mode 100644 index 0000000..fe993df --- /dev/null +++ b/app/Http/Controllers/Notification/NotificationWebhookController.php @@ -0,0 +1,90 @@ +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', []), + ]]; + } +} diff --git a/app/Http/Controllers/Payment/PaymentGatewayController.php b/app/Http/Controllers/Payment/PaymentGatewayController.php new file mode 100644 index 0000000..f27895e --- /dev/null +++ b/app/Http/Controllers/Payment/PaymentGatewayController.php @@ -0,0 +1,52 @@ +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'); + } +} diff --git a/app/Http/Controllers/Topology/TopologyController.php b/app/Http/Controllers/Topology/TopologyController.php new file mode 100644 index 0000000..5ebbb48 --- /dev/null +++ b/app/Http/Controllers/Topology/TopologyController.php @@ -0,0 +1,154 @@ +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); + } +} diff --git a/app/Http/Controllers/Wallet/WalletController.php b/app/Http/Controllers/Wallet/WalletController.php new file mode 100644 index 0000000..038fcb8 --- /dev/null +++ b/app/Http/Controllers/Wallet/WalletController.php @@ -0,0 +1,40 @@ +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'); + } +} diff --git a/app/Http/Requests/Billing/IndexBillingRequest.php b/app/Http/Requests/Billing/IndexBillingRequest.php new file mode 100644 index 0000000..cc315c4 --- /dev/null +++ b/app/Http/Requests/Billing/IndexBillingRequest.php @@ -0,0 +1,28 @@ + ['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'])], + ]; + } +} diff --git a/app/Http/Requests/Billing/PayInvoiceRequest.php b/app/Http/Requests/Billing/PayInvoiceRequest.php new file mode 100644 index 0000000..6701d07 --- /dev/null +++ b/app/Http/Requests/Billing/PayInvoiceRequest.php @@ -0,0 +1,24 @@ + ['required', 'numeric', 'gt:0'], + 'payment_method' => ['nullable', 'string', 'max:50'], + 'payment_reference' => ['nullable', 'string', 'max:255'], + 'paid_at' => ['nullable', 'date'], + 'notes' => ['nullable', 'string'], + ]; + } +} diff --git a/app/Http/Requests/Billing/StoreBillingProfileRequest.php b/app/Http/Requests/Billing/StoreBillingProfileRequest.php new file mode 100644 index 0000000..4f114bb --- /dev/null +++ b/app/Http/Requests/Billing/StoreBillingProfileRequest.php @@ -0,0 +1,35 @@ + ['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'], + ]; + } +} diff --git a/app/Http/Requests/Billing/StoreInvoiceRequest.php b/app/Http/Requests/Billing/StoreInvoiceRequest.php new file mode 100644 index 0000000..1943917 --- /dev/null +++ b/app/Http/Requests/Billing/StoreInvoiceRequest.php @@ -0,0 +1,33 @@ + ['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'])], + ]; + } +} diff --git a/app/Http/Requests/Billing/UpdateBillingProfileRequest.php b/app/Http/Requests/Billing/UpdateBillingProfileRequest.php new file mode 100644 index 0000000..37c91bf --- /dev/null +++ b/app/Http/Requests/Billing/UpdateBillingProfileRequest.php @@ -0,0 +1,18 @@ +map(function ($rules) { + $rules = is_array($rules) ? $rules : [$rules]; + if (($index = array_search('required', $rules, true)) !== false) { + $rules[$index] = 'sometimes'; + } + + return $rules; + })->all(); + } +} diff --git a/app/Http/Requests/Billing/UpdateInvoiceRequest.php b/app/Http/Requests/Billing/UpdateInvoiceRequest.php new file mode 100644 index 0000000..7e72ae3 --- /dev/null +++ b/app/Http/Requests/Billing/UpdateInvoiceRequest.php @@ -0,0 +1,18 @@ +map(function ($rules) { + $rules = is_array($rules) ? $rules : [$rules]; + if (($index = array_search('required', $rules, true)) !== false) { + $rules[$index] = 'sometimes'; + } + + return $rules; + })->all(); + } +} diff --git a/app/Http/Requests/Customer/ActivateCustomerRequest.php b/app/Http/Requests/Customer/ActivateCustomerRequest.php new file mode 100644 index 0000000..62fadcc --- /dev/null +++ b/app/Http/Requests/Customer/ActivateCustomerRequest.php @@ -0,0 +1,24 @@ + ['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'], + ]; + } +} diff --git a/app/Http/Requests/Customer/IndexCustomerRequest.php b/app/Http/Requests/Customer/IndexCustomerRequest.php new file mode 100644 index 0000000..e851606 --- /dev/null +++ b/app/Http/Requests/Customer/IndexCustomerRequest.php @@ -0,0 +1,26 @@ + ['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'])], + ]; + } +} diff --git a/app/Http/Requests/Customer/StoreCustomerRequest.php b/app/Http/Requests/Customer/StoreCustomerRequest.php new file mode 100644 index 0000000..5c70a0a --- /dev/null +++ b/app/Http/Requests/Customer/StoreCustomerRequest.php @@ -0,0 +1,48 @@ + ['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'], + ]; + } +} diff --git a/app/Http/Requests/Customer/UpdateCustomerRequest.php b/app/Http/Requests/Customer/UpdateCustomerRequest.php new file mode 100644 index 0000000..f20340c --- /dev/null +++ b/app/Http/Requests/Customer/UpdateCustomerRequest.php @@ -0,0 +1,14 @@ +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.', ]; diff --git a/app/Http/Requests/Notification/IndexNotificationRequest.php b/app/Http/Requests/Notification/IndexNotificationRequest.php new file mode 100644 index 0000000..d8ed040 --- /dev/null +++ b/app/Http/Requests/Notification/IndexNotificationRequest.php @@ -0,0 +1,24 @@ + ['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'], + ]; + } +} diff --git a/app/Http/Requests/Notification/SaveNotificationPreferencesRequest.php b/app/Http/Requests/Notification/SaveNotificationPreferencesRequest.php new file mode 100644 index 0000000..bdc7cfb --- /dev/null +++ b/app/Http/Requests/Notification/SaveNotificationPreferencesRequest.php @@ -0,0 +1,30 @@ + ['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'], + ]; + } +} diff --git a/app/Http/Requests/Notification/StoreCustomerContactRequest.php b/app/Http/Requests/Notification/StoreCustomerContactRequest.php new file mode 100644 index 0000000..80f8e99 --- /dev/null +++ b/app/Http/Requests/Notification/StoreCustomerContactRequest.php @@ -0,0 +1,28 @@ + ['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'], + ]; + } +} diff --git a/app/Http/Requests/Notification/StoreNotificationBroadcastRequest.php b/app/Http/Requests/Notification/StoreNotificationBroadcastRequest.php new file mode 100644 index 0000000..fe0c336 --- /dev/null +++ b/app/Http/Requests/Notification/StoreNotificationBroadcastRequest.php @@ -0,0 +1,39 @@ + ['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'], + ]; + } +} diff --git a/app/Http/Requests/Notification/StoreNotificationChannelRequest.php b/app/Http/Requests/Notification/StoreNotificationChannelRequest.php new file mode 100644 index 0000000..39afc05 --- /dev/null +++ b/app/Http/Requests/Notification/StoreNotificationChannelRequest.php @@ -0,0 +1,29 @@ + ['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'], + ]; + } +} diff --git a/app/Http/Requests/Notification/StoreNotificationTemplateRequest.php b/app/Http/Requests/Notification/StoreNotificationTemplateRequest.php new file mode 100644 index 0000000..87114b6 --- /dev/null +++ b/app/Http/Requests/Notification/StoreNotificationTemplateRequest.php @@ -0,0 +1,32 @@ + ['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'], + ]; + } +} diff --git a/app/Http/Requests/Payment/IndexPaymentRequest.php b/app/Http/Requests/Payment/IndexPaymentRequest.php new file mode 100644 index 0000000..96350de --- /dev/null +++ b/app/Http/Requests/Payment/IndexPaymentRequest.php @@ -0,0 +1,26 @@ + ['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'])], + ]; + } +} diff --git a/app/Http/Requests/Payment/SaveTenantPaymentSettingRequest.php b/app/Http/Requests/Payment/SaveTenantPaymentSettingRequest.php new file mode 100644 index 0000000..9612c94 --- /dev/null +++ b/app/Http/Requests/Payment/SaveTenantPaymentSettingRequest.php @@ -0,0 +1,28 @@ + ['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'], + ]; + } +} diff --git a/app/Http/Requests/Payment/StoreGatewayAccountRequest.php b/app/Http/Requests/Payment/StoreGatewayAccountRequest.php new file mode 100644 index 0000000..4ea5adf --- /dev/null +++ b/app/Http/Requests/Payment/StoreGatewayAccountRequest.php @@ -0,0 +1,31 @@ + ['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'], + ]; + } +} diff --git a/app/Http/Requests/Topology/IndexTopologyRequest.php b/app/Http/Requests/Topology/IndexTopologyRequest.php new file mode 100644 index 0000000..e2a9214 --- /dev/null +++ b/app/Http/Requests/Topology/IndexTopologyRequest.php @@ -0,0 +1,29 @@ + ['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'])], + ]; + } +} diff --git a/app/Http/Requests/Topology/StoreCustomerConnectionRequest.php b/app/Http/Requests/Topology/StoreCustomerConnectionRequest.php new file mode 100644 index 0000000..f52c179 --- /dev/null +++ b/app/Http/Requests/Topology/StoreCustomerConnectionRequest.php @@ -0,0 +1,27 @@ + ['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'], + ]; + } +} diff --git a/app/Http/Requests/Topology/StoreTopologyRequest.php b/app/Http/Requests/Topology/StoreTopologyRequest.php new file mode 100644 index 0000000..4e2d439 --- /dev/null +++ b/app/Http/Requests/Topology/StoreTopologyRequest.php @@ -0,0 +1,89 @@ +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']]; + } +} diff --git a/app/Http/Requests/Topology/UpdateTopologyRequest.php b/app/Http/Requests/Topology/UpdateTopologyRequest.php new file mode 100644 index 0000000..1679a02 --- /dev/null +++ b/app/Http/Requests/Topology/UpdateTopologyRequest.php @@ -0,0 +1,18 @@ +map(function ($rules) { + $rules = is_array($rules) ? $rules : [$rules]; + if (($index = array_search('required', $rules, true)) !== false) { + $rules[$index] = 'sometimes'; + } + + return $rules; + })->all(); + } +} diff --git a/app/Http/Requests/Wallet/StoreWithdrawalRequest.php b/app/Http/Requests/Wallet/StoreWithdrawalRequest.php new file mode 100644 index 0000000..b12aef3 --- /dev/null +++ b/app/Http/Requests/Wallet/StoreWithdrawalRequest.php @@ -0,0 +1,22 @@ + ['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'], + ]; + } +} diff --git a/app/Http/Requests/Wallet/TransferWalletRequest.php b/app/Http/Requests/Wallet/TransferWalletRequest.php new file mode 100644 index 0000000..84d07b6 --- /dev/null +++ b/app/Http/Requests/Wallet/TransferWalletRequest.php @@ -0,0 +1,18 @@ + ['required', 'integer', 'exists:wallets,id'], 'amount' => ['required', 'numeric', 'min:1']]; + } +} diff --git a/app/Http/Resources/Billing/BillingProfileResource.php b/app/Http/Resources/Billing/BillingProfileResource.php new file mode 100644 index 0000000..fae26a8 --- /dev/null +++ b/app/Http/Resources/Billing/BillingProfileResource.php @@ -0,0 +1,14 @@ + $this->whenLoaded('tenant')]; + } +} diff --git a/app/Http/Resources/Billing/InvoiceResource.php b/app/Http/Resources/Billing/InvoiceResource.php new file mode 100644 index 0000000..dc6a793 --- /dev/null +++ b/app/Http/Resources/Billing/InvoiceResource.php @@ -0,0 +1,19 @@ + $this->whenLoaded('tenant'), + 'customer' => $this->whenLoaded('customer'), + 'billing_profile' => $this->whenLoaded('billingProfile'), + ]; + } +} diff --git a/app/Http/Resources/Customer/CustomerResource.php b/app/Http/Resources/Customer/CustomerResource.php new file mode 100644 index 0000000..8477766 --- /dev/null +++ b/app/Http/Resources/Customer/CustomerResource.php @@ -0,0 +1,50 @@ + $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(', '), + ]; + } +} diff --git a/app/Http/Resources/Notification/NotificationDeliveryResource.php b/app/Http/Resources/Notification/NotificationDeliveryResource.php new file mode 100644 index 0000000..da6c0f0 --- /dev/null +++ b/app/Http/Resources/Notification/NotificationDeliveryResource.php @@ -0,0 +1,24 @@ +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'), + ]; + } +} diff --git a/app/Http/Resources/Payment/PaymentTransactionResource.php b/app/Http/Resources/Payment/PaymentTransactionResource.php new file mode 100644 index 0000000..23e05ee --- /dev/null +++ b/app/Http/Resources/Payment/PaymentTransactionResource.php @@ -0,0 +1,24 @@ + $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, + ]; + } +} diff --git a/app/Http/Resources/Topology/TopologyResource.php b/app/Http/Resources/Topology/TopologyResource.php new file mode 100644 index 0000000..1c9d559 --- /dev/null +++ b/app/Http/Resources/Topology/TopologyResource.php @@ -0,0 +1,24 @@ + $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'), + ]; + } +} diff --git a/app/Jobs/ProcessBroadcast.php b/app/Jobs/ProcessBroadcast.php new file mode 100644 index 0000000..d67ce64 --- /dev/null +++ b/app/Jobs/ProcessBroadcast.php @@ -0,0 +1,25 @@ +find($this->broadcastId); + if ($broadcast) { + $service->processBroadcast($broadcast); + } + } +} diff --git a/app/Jobs/ProcessNotificationDelivery.php b/app/Jobs/ProcessNotificationDelivery.php new file mode 100644 index 0000000..e0642bb --- /dev/null +++ b/app/Jobs/ProcessNotificationDelivery.php @@ -0,0 +1,31 @@ +deliveryId); + if (! $delivery || in_array($delivery->status, ['sent', 'delivered', 'read', 'cancelled', 'skipped'], true)) { + return; + } + $manager->deliver($delivery); + } +} diff --git a/app/Models/BillingProfile.php b/app/Models/BillingProfile.php new file mode 100644 index 0000000..49df594 --- /dev/null +++ b/app/Models/BillingProfile.php @@ -0,0 +1,30 @@ + '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); + } +} diff --git a/app/Models/Customer.php b/app/Models/Customer.php new file mode 100644 index 0000000..8e5dab3 --- /dev/null +++ b/app/Models/Customer.php @@ -0,0 +1,98 @@ + '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); + } +} diff --git a/app/Models/CustomerContact.php b/app/Models/CustomerContact.php new file mode 100644 index 0000000..544e9a2 --- /dev/null +++ b/app/Models/CustomerContact.php @@ -0,0 +1,21 @@ + '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); } +} diff --git a/app/Models/CustomerImage.php b/app/Models/CustomerImage.php new file mode 100644 index 0000000..931aebf --- /dev/null +++ b/app/Models/CustomerImage.php @@ -0,0 +1,25 @@ + 'boolean']; + } + + public function customer() + { + return $this->belongsTo(Customer::class); + } + + public function file() + { + return $this->belongsTo(StoredFile::class, 'stored_file_id'); + } +} diff --git a/app/Models/Invoice.php b/app/Models/Invoice.php new file mode 100644 index 0000000..a8ee70a --- /dev/null +++ b/app/Models/Invoice.php @@ -0,0 +1,69 @@ + '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); + } +} diff --git a/app/Models/NotificationBroadcast.php b/app/Models/NotificationBroadcast.php new file mode 100644 index 0000000..0af96ba --- /dev/null +++ b/app/Models/NotificationBroadcast.php @@ -0,0 +1,35 @@ + '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); + } +} diff --git a/app/Models/NotificationBroadcastRecipient.php b/app/Models/NotificationBroadcastRecipient.php new file mode 100644 index 0000000..dfa3705 --- /dev/null +++ b/app/Models/NotificationBroadcastRecipient.php @@ -0,0 +1,20 @@ +belongsTo(NotificationBroadcast::class, 'notification_broadcast_id'); + } + + public function customer() + { + return $this->belongsTo(Customer::class); + } +} diff --git a/app/Models/NotificationChannel.php b/app/Models/NotificationChannel.php new file mode 100644 index 0000000..b86326f --- /dev/null +++ b/app/Models/NotificationChannel.php @@ -0,0 +1,33 @@ + '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); + } +} diff --git a/app/Models/NotificationDelivery.php b/app/Models/NotificationDelivery.php new file mode 100644 index 0000000..0a3323b --- /dev/null +++ b/app/Models/NotificationDelivery.php @@ -0,0 +1,29 @@ + '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'); + } +} diff --git a/app/Models/NotificationEvent.php b/app/Models/NotificationEvent.php new file mode 100644 index 0000000..3825657 --- /dev/null +++ b/app/Models/NotificationEvent.php @@ -0,0 +1,15 @@ + 'array', 'variables' => 'array', 'is_system' => 'boolean', 'enabled' => 'boolean']; + } +} diff --git a/app/Models/NotificationEventOccurrence.php b/app/Models/NotificationEventOccurrence.php new file mode 100644 index 0000000..1c2ef22 --- /dev/null +++ b/app/Models/NotificationEventOccurrence.php @@ -0,0 +1,20 @@ + 'datetime']; + } + + public function subjectable() + { + return $this->morphTo(); + } +} diff --git a/app/Models/NotificationMessage.php b/app/Models/NotificationMessage.php new file mode 100644 index 0000000..b30979f --- /dev/null +++ b/app/Models/NotificationMessage.php @@ -0,0 +1,45 @@ + '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(); + } +} diff --git a/app/Models/NotificationPreference.php b/app/Models/NotificationPreference.php new file mode 100644 index 0000000..d733cfd --- /dev/null +++ b/app/Models/NotificationPreference.php @@ -0,0 +1,25 @@ + 'boolean']; + } + + public function event() + { + return $this->belongsTo(NotificationEvent::class, 'notification_event_id'); + } + + public function channelConfig() + { + return $this->belongsTo(NotificationChannel::class, 'notification_channel_id'); + } +} diff --git a/app/Models/NotificationTemplate.php b/app/Models/NotificationTemplate.php new file mode 100644 index 0000000..866aa63 --- /dev/null +++ b/app/Models/NotificationTemplate.php @@ -0,0 +1,30 @@ + '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); + } +} diff --git a/app/Models/NotificationWebhookLog.php b/app/Models/NotificationWebhookLog.php new file mode 100644 index 0000000..8b9b2f4 --- /dev/null +++ b/app/Models/NotificationWebhookLog.php @@ -0,0 +1,15 @@ + 'boolean', 'payload' => 'array', 'processed_at' => 'datetime']; + } +} diff --git a/app/Models/PaymentAttempt.php b/app/Models/PaymentAttempt.php new file mode 100644 index 0000000..d6b6b30 --- /dev/null +++ b/app/Models/PaymentAttempt.php @@ -0,0 +1,20 @@ + 'array', 'response_payload' => 'array']; + } + + public function transaction() + { + return $this->belongsTo(PaymentTransaction::class, 'payment_transaction_id'); + } +} diff --git a/app/Models/PaymentGatewayAccount.php b/app/Models/PaymentGatewayAccount.php new file mode 100644 index 0000000..ba0f850 --- /dev/null +++ b/app/Models/PaymentGatewayAccount.php @@ -0,0 +1,34 @@ + '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); + } +} diff --git a/app/Models/PaymentProvider.php b/app/Models/PaymentProvider.php new file mode 100644 index 0000000..1a87cc3 --- /dev/null +++ b/app/Models/PaymentProvider.php @@ -0,0 +1,15 @@ + 'array', 'credential_schema' => 'array', 'enabled' => 'boolean']; + } +} diff --git a/app/Models/PaymentTransaction.php b/app/Models/PaymentTransaction.php new file mode 100644 index 0000000..90d19fb --- /dev/null +++ b/app/Models/PaymentTransaction.php @@ -0,0 +1,49 @@ + '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); + } +} diff --git a/app/Models/PaymentWebhookLog.php b/app/Models/PaymentWebhookLog.php new file mode 100644 index 0000000..28909ed --- /dev/null +++ b/app/Models/PaymentWebhookLog.php @@ -0,0 +1,15 @@ + 'boolean', 'headers' => 'array', 'payload' => 'array', 'processed_at' => 'datetime']; + } +} diff --git a/app/Models/Tenant.php b/app/Models/Tenant.php index 2447e7c..1069740 100644 --- a/app/Models/Tenant.php +++ b/app/Models/Tenant.php @@ -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); + } } diff --git a/app/Models/TenantPaymentSetting.php b/app/Models/TenantPaymentSetting.php new file mode 100644 index 0000000..4bc1a38 --- /dev/null +++ b/app/Models/TenantPaymentSetting.php @@ -0,0 +1,30 @@ + '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'); + } +} diff --git a/app/Models/TopologyConnectionRule.php b/app/Models/TopologyConnectionRule.php new file mode 100644 index 0000000..d0a1902 --- /dev/null +++ b/app/Models/TopologyConnectionRule.php @@ -0,0 +1,15 @@ + 'boolean']; + } +} diff --git a/app/Models/TopologyCustomerConnection.php b/app/Models/TopologyCustomerConnection.php new file mode 100644 index 0000000..0f1e3d7 --- /dev/null +++ b/app/Models/TopologyCustomerConnection.php @@ -0,0 +1,34 @@ + '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'); + } +} diff --git a/app/Models/TopologyDeviceType.php b/app/Models/TopologyDeviceType.php new file mode 100644 index 0000000..b839a1a --- /dev/null +++ b/app/Models/TopologyDeviceType.php @@ -0,0 +1,24 @@ + 'boolean', + 'can_have_splitters' => 'boolean', + 'is_system' => 'boolean', + ]; + } + + public function nodes() + { + return $this->hasMany(TopologyNode::class, 'device_type_id'); + } +} diff --git a/app/Models/TopologyLink.php b/app/Models/TopologyLink.php new file mode 100644 index 0000000..e530b25 --- /dev/null +++ b/app/Models/TopologyLink.php @@ -0,0 +1,47 @@ + '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'); + } +} diff --git a/app/Models/TopologyNode.php b/app/Models/TopologyNode.php new file mode 100644 index 0000000..716c8b8 --- /dev/null +++ b/app/Models/TopologyNode.php @@ -0,0 +1,53 @@ + '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'); + } +} diff --git a/app/Models/TopologyPort.php b/app/Models/TopologyPort.php new file mode 100644 index 0000000..c7be3c6 --- /dev/null +++ b/app/Models/TopologyPort.php @@ -0,0 +1,20 @@ + 'array']; + } + + public function node() + { + return $this->belongsTo(TopologyNode::class, 'node_id'); + } +} diff --git a/app/Models/TopologySplitter.php b/app/Models/TopologySplitter.php new file mode 100644 index 0000000..c753dc6 --- /dev/null +++ b/app/Models/TopologySplitter.php @@ -0,0 +1,15 @@ + 'array']; + } +} diff --git a/app/Models/User.php b/app/Models/User.php index 586e9d1..be8b78a 100644 --- a/app/Models/User.php +++ b/app/Models/User.php @@ -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; diff --git a/app/Models/UserNotification.php b/app/Models/UserNotification.php new file mode 100644 index 0000000..76cc895 --- /dev/null +++ b/app/Models/UserNotification.php @@ -0,0 +1,17 @@ + 'datetime']; + } + + public function user() { return $this->belongsTo(User::class); } +} diff --git a/app/Models/Wallet.php b/app/Models/Wallet.php new file mode 100644 index 0000000..788bb81 --- /dev/null +++ b/app/Models/Wallet.php @@ -0,0 +1,25 @@ + 'decimal:2', 'pending_balance' => 'decimal:2', 'locked_balance' => 'decimal:2']; + } + + public function owner() + { + return $this->morphTo(); + } + + public function ledgerEntries() + { + return $this->hasMany(WalletLedgerEntry::class); + } +} diff --git a/app/Models/WalletLedgerEntry.php b/app/Models/WalletLedgerEntry.php new file mode 100644 index 0000000..f66c1f8 --- /dev/null +++ b/app/Models/WalletLedgerEntry.php @@ -0,0 +1,25 @@ + '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'); + } +} diff --git a/app/Models/WalletTransaction.php b/app/Models/WalletTransaction.php new file mode 100644 index 0000000..2936e22 --- /dev/null +++ b/app/Models/WalletTransaction.php @@ -0,0 +1,25 @@ + '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'); + } +} diff --git a/app/Models/WalletWithdrawal.php b/app/Models/WalletWithdrawal.php new file mode 100644 index 0000000..0220224 --- /dev/null +++ b/app/Models/WalletWithdrawal.php @@ -0,0 +1,20 @@ + 'decimal:2', 'fee' => 'decimal:2', 'processed_at' => 'datetime']; + } + + public function wallet() + { + return $this->belongsTo(Wallet::class); + } +} diff --git a/app/Services/Billing/BillingProfileService.php b/app/Services/Billing/BillingProfileService.php new file mode 100644 index 0000000..718cc1e --- /dev/null +++ b/app/Services/Billing/BillingProfileService.php @@ -0,0 +1,81 @@ +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; + } +} diff --git a/app/Services/Billing/InvoiceService.php b/app/Services/Billing/InvoiceService.php new file mode 100644 index 0000000..8755fc5 --- /dev/null +++ b/app/Services/Billing/InvoiceService.php @@ -0,0 +1,346 @@ +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); + } +} diff --git a/app/Services/Customer/CustomerService.php b/app/Services/Customer/CustomerService.php new file mode 100644 index 0000000..0ff7d2b --- /dev/null +++ b/app/Services/Customer/CustomerService.php @@ -0,0 +1,283 @@ +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, + ], + ); + } + } +} diff --git a/app/Services/Notification/Adapters/EmailAdapter.php b/app/Services/Notification/Adapters/EmailAdapter.php new file mode 100644 index 0000000..4d63526 --- /dev/null +++ b/app/Services/Notification/Adapters/EmailAdapter.php @@ -0,0 +1,50 @@ +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]]; + } +} diff --git a/app/Services/Notification/Adapters/SystemAdapter.php b/app/Services/Notification/Adapters/SystemAdapter.php new file mode 100644 index 0000000..1d9c720 --- /dev/null +++ b/app/Services/Notification/Adapters/SystemAdapter.php @@ -0,0 +1,45 @@ +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]]; + } +} diff --git a/app/Services/Notification/Adapters/TelegramAdapter.php b/app/Services/Notification/Adapters/TelegramAdapter.php new file mode 100644 index 0000000..787109e --- /dev/null +++ b/app/Services/Notification/Adapters/TelegramAdapter.php @@ -0,0 +1,26 @@ +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]; + } +} diff --git a/app/Services/Notification/Adapters/WhatsAppOfficialAdapter.php b/app/Services/Notification/Adapters/WhatsAppOfficialAdapter.php new file mode 100644 index 0000000..b1a115b --- /dev/null +++ b/app/Services/Notification/Adapters/WhatsAppOfficialAdapter.php @@ -0,0 +1,39 @@ +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]; + } +} diff --git a/app/Services/Notification/Adapters/WhatsAppUnofficialAdapter.php b/app/Services/Notification/Adapters/WhatsAppUnofficialAdapter.php new file mode 100644 index 0000000..64fcda8 --- /dev/null +++ b/app/Services/Notification/Adapters/WhatsAppUnofficialAdapter.php @@ -0,0 +1,34 @@ +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]; + } +} diff --git a/app/Services/Notification/Contracts/NotificationChannelAdapter.php b/app/Services/Notification/Contracts/NotificationChannelAdapter.php new file mode 100644 index 0000000..a2747de --- /dev/null +++ b/app/Services/Notification/Contracts/NotificationChannelAdapter.php @@ -0,0 +1,10 @@ +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', + ]); + } +} diff --git a/app/Services/Notification/NotificationService.php b/app/Services/Notification/NotificationService.php new file mode 100644 index 0000000..a3f54dd --- /dev/null +++ b/app/Services/Notification/NotificationService.php @@ -0,0 +1,395 @@ +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(); + } +} diff --git a/app/Services/Payment/PaymentGatewayService.php b/app/Services/Payment/PaymentGatewayService.php new file mode 100644 index 0000000..23c50e0 --- /dev/null +++ b/app/Services/Payment/PaymentGatewayService.php @@ -0,0 +1,138 @@ +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; + } +} diff --git a/app/Services/Topology/TopologyService.php b/app/Services/Topology/TopologyService.php new file mode 100644 index 0000000..75a79b5 --- /dev/null +++ b/app/Services/Topology/TopologyService.php @@ -0,0 +1,421 @@ + [], + '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.']), + }; + } +} diff --git a/app/Services/Wallet/WalletService.php b/app/Services/Wallet/WalletService.php new file mode 100644 index 0000000..38a2f2f --- /dev/null +++ b/app/Services/Wallet/WalletService.php @@ -0,0 +1,123 @@ +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.'); + } +} diff --git a/config/app.php b/config/app.php index 324b513..6800aa1 100644 --- a/config/app.php +++ b/config/app.php @@ -65,7 +65,7 @@ return [ | */ - 'timezone' => 'UTC', + 'timezone' => env('APP_TIMEZONE', 'Asia/Jakarta'), /* |-------------------------------------------------------------------------- diff --git a/database/migrations/2026_07_27_150000_create_customers_table.php b/database/migrations/2026_07_27_150000_create_customers_table.php new file mode 100644 index 0000000..94c6648 --- /dev/null +++ b/database/migrations/2026_07_27_150000_create_customers_table.php @@ -0,0 +1,51 @@ +id(); + $table->foreignId('tenant_id')->constrained()->cascadeOnDelete(); + $table->foreignId('user_id')->nullable()->constrained()->nullOnDelete(); + $table->string('customer_code'); + $table->string('name'); + $table->string('identity_number', 100)->nullable(); + $table->string('email')->nullable(); + $table->string('whatsapp_number', 50)->nullable(); + $table->text('installation_address')->nullable(); + $table->foreignId('provinsi_id')->nullable()->constrained('wilayah_provinsi')->nullOnDelete(); + $table->foreignId('kabupaten_id')->nullable()->constrained('wilayah_kabupaten')->nullOnDelete(); + $table->foreignId('kecamatan_id')->nullable()->constrained('wilayah_kecamatan')->nullOnDelete(); + $table->foreignId('desa_id')->nullable()->constrained('wilayah_desa')->nullOnDelete(); + $table->decimal('latitude', 10, 7)->nullable(); + $table->decimal('longitude', 10, 7)->nullable(); + $table->foreignId('package_profile_id')->nullable()->constrained('nas_package_profiles')->nullOnDelete(); + $table->foreignId('nas_mikrotik_id')->nullable()->constrained('nas_mikrotiks')->nullOnDelete(); + $table->unsignedBigInteger('billing_profile_id')->nullable()->index(); + $table->unsignedBigInteger('topology_id')->nullable()->index(); + $table->string('external_username')->nullable(); + $table->string('source', 20)->default('manual'); + $table->string('status', 20)->default('order'); + $table->string('order_stage', 30)->default('registration'); + $table->timestamp('activated_at')->nullable(); + $table->timestamp('inactivated_at')->nullable(); + $table->text('notes')->nullable(); + $table->timestamps(); + $table->softDeletes(); + + $table->unique(['tenant_id', 'customer_code']); + $table->index(['tenant_id', 'status']); + $table->index(['tenant_id', 'source', 'external_username']); + }); + } + + public function down(): void + { + Schema::dropIfExists('customers'); + } +}; diff --git a/database/migrations/2026_07_27_160000_create_customer_images_table.php b/database/migrations/2026_07_27_160000_create_customer_images_table.php new file mode 100644 index 0000000..d1d96b3 --- /dev/null +++ b/database/migrations/2026_07_27_160000_create_customer_images_table.php @@ -0,0 +1,29 @@ +id(); + $table->foreignId('customer_id')->constrained()->cascadeOnDelete(); + $table->foreignId('stored_file_id')->constrained('stored_files')->cascadeOnDelete(); + $table->string('category', 30)->default('other'); + $table->string('caption')->nullable(); + $table->boolean('is_cover')->default(false); + $table->unsignedSmallInteger('sort_order')->default(0); + $table->timestamps(); + $table->unique(['customer_id', 'stored_file_id']); + $table->index(['customer_id', 'is_cover']); + }); + } + + public function down(): void + { + Schema::dropIfExists('customer_images'); + } +}; diff --git a/database/migrations/2026_07_28_090000_create_billing_tables.php b/database/migrations/2026_07_28_090000_create_billing_tables.php new file mode 100644 index 0000000..fb33e54 --- /dev/null +++ b/database/migrations/2026_07_28_090000_create_billing_tables.php @@ -0,0 +1,89 @@ +id(); + $table->foreignId('tenant_id')->constrained()->cascadeOnDelete(); + $table->string('name'); + $table->unsignedTinyInteger('invoice_day'); + $table->unsignedTinyInteger('send_day'); + $table->unsignedTinyInteger('warning_day'); + $table->unsignedTinyInteger('isolation_day'); + $table->string('tax_type', 20)->default('non_tax'); + $table->decimal('tax_rate', 5, 2)->default(0); + $table->string('status', 20)->default('active'); + $table->text('notes')->nullable(); + $table->timestamps(); + $table->unique(['tenant_id', 'name']); + $table->index(['tenant_id', 'status']); + }); + + Schema::table('customers', function (Blueprint $table) { + $table->foreign('billing_profile_id') + ->references('id') + ->on('billing_profiles') + ->nullOnDelete(); + }); + + Schema::create('invoices', function (Blueprint $table) { + $table->id(); + $table->foreignId('tenant_id')->constrained()->cascadeOnDelete(); + $table->foreignId('customer_id')->constrained()->restrictOnDelete(); + $table->foreignId('billing_profile_id')->nullable()->constrained()->nullOnDelete(); + $table->foreignId('created_by')->nullable()->constrained('users')->nullOnDelete(); + $table->foreignId('updated_by')->nullable()->constrained('users')->nullOnDelete(); + $table->foreignId('paid_by')->nullable()->constrained('users')->nullOnDelete(); + $table->string('invoice_number'); + $table->date('period_start'); + $table->date('period_end'); + $table->date('issue_date'); + $table->date('send_date')->nullable(); + $table->date('warning_date')->nullable(); + $table->date('isolation_date')->nullable(); + $table->decimal('subtotal', 15, 2); + $table->string('tax_type', 20)->default('non_tax'); + $table->decimal('tax_rate', 5, 2)->default(0); + $table->decimal('tax_amount', 15, 2)->default(0); + $table->decimal('total', 15, 2); + $table->decimal('paid_amount', 15, 2)->default(0); + $table->decimal('balance', 15, 2); + $table->string('status', 20)->default('draft'); + $table->string('payment_status', 20)->default('unpaid'); + $table->timestamp('paid_at')->nullable(); + $table->string('payment_method', 50)->nullable(); + $table->string('payment_reference')->nullable(); + $table->string('external_payment_id')->nullable()->index(); + $table->string('finance_reference')->nullable()->index(); + $table->string('notification_status', 30)->default('pending'); + $table->string('customer_code_snapshot'); + $table->string('customer_name_snapshot'); + $table->text('customer_address_snapshot')->nullable(); + $table->string('package_name_snapshot')->nullable(); + $table->json('profile_snapshot')->nullable(); + $table->json('metadata')->nullable(); + $table->text('notes')->nullable(); + $table->timestamps(); + $table->softDeletes(); + $table->unique(['tenant_id', 'invoice_number']); + $table->index(['tenant_id', 'status', 'issue_date']); + $table->index(['tenant_id', 'payment_status', 'isolation_date']); + $table->index(['customer_id', 'period_start', 'period_end']); + }); + } + + public function down(): void + { + Schema::dropIfExists('invoices'); + Schema::table('customers', function (Blueprint $table) { + $table->dropForeign(['billing_profile_id']); + }); + Schema::dropIfExists('billing_profiles'); + } +}; diff --git a/database/migrations/2026_07_28_100000_add_schedule_type_to_billing_profiles.php b/database/migrations/2026_07_28_100000_add_schedule_type_to_billing_profiles.php new file mode 100644 index 0000000..39d38ad --- /dev/null +++ b/database/migrations/2026_07_28_100000_add_schedule_type_to_billing_profiles.php @@ -0,0 +1,34 @@ +string('schedule_type', 30)->default('fixed_date')->after('name'); + $table->unsignedTinyInteger('invoice_days_before')->nullable()->after('isolation_day'); + $table->unsignedTinyInteger('send_days_before')->nullable()->after('invoice_days_before'); + $table->unsignedTinyInteger('warning_days_before')->nullable()->after('send_days_before'); + $table->unsignedTinyInteger('isolation_days_after')->nullable()->after('warning_days_before'); + $table->index(['tenant_id', 'schedule_type']); + }); + } + + public function down(): void + { + Schema::table('billing_profiles', function (Blueprint $table) { + $table->dropIndex(['tenant_id', 'schedule_type']); + $table->dropColumn([ + 'schedule_type', + 'invoice_days_before', + 'send_days_before', + 'warning_days_before', + 'isolation_days_after', + ]); + }); + } +}; diff --git a/database/migrations/2026_07_28_110000_prevent_duplicate_customer_invoices.php b/database/migrations/2026_07_28_110000_prevent_duplicate_customer_invoices.php new file mode 100644 index 0000000..75b00e6 --- /dev/null +++ b/database/migrations/2026_07_28_110000_prevent_duplicate_customer_invoices.php @@ -0,0 +1,25 @@ +unique( + ['customer_id', 'period_start', 'period_end'], + 'invoices_customer_period_unique', + ); + }); + } + + public function down(): void + { + Schema::table('invoices', function (Blueprint $table) { + $table->dropUnique('invoices_customer_period_unique'); + }); + } +}; diff --git a/database/migrations/2026_07_28_120000_create_topology_tables.php b/database/migrations/2026_07_28_120000_create_topology_tables.php new file mode 100644 index 0000000..af5e418 --- /dev/null +++ b/database/migrations/2026_07_28_120000_create_topology_tables.php @@ -0,0 +1,153 @@ +id(); + $table->foreignId('tenant_id')->nullable()->constrained()->cascadeOnDelete(); + $table->string('code', 50); + $table->string('name'); + $table->string('category', 50); + $table->boolean('can_have_ports')->default(true); + $table->boolean('can_have_splitters')->default(false); + $table->boolean('is_system')->default(false); + $table->string('icon')->nullable(); + $table->string('color', 20)->default('#2f74c0'); + $table->string('status', 20)->default('active'); + $table->timestamps(); + $table->unique(['tenant_id', 'code']); + $table->index(['tenant_id', 'status']); + }); + + Schema::create('topology_nodes', function (Blueprint $table) { + $table->id(); + $table->foreignId('tenant_id')->constrained()->cascadeOnDelete(); + $table->foreignId('device_type_id')->constrained('topology_device_types')->restrictOnDelete(); + $table->foreignId('parent_node_id')->nullable()->constrained('topology_nodes')->nullOnDelete(); + $table->foreignId('provinsi_id')->nullable()->constrained('wilayah_provinsi')->nullOnDelete(); + $table->foreignId('kabupaten_id')->nullable()->constrained('wilayah_kabupaten')->nullOnDelete(); + $table->foreignId('kecamatan_id')->nullable()->constrained('wilayah_kecamatan')->nullOnDelete(); + $table->foreignId('desa_id')->nullable()->constrained('wilayah_desa')->nullOnDelete(); + $table->foreignId('created_by')->nullable()->constrained('users')->nullOnDelete(); + $table->foreignId('updated_by')->nullable()->constrained('users')->nullOnDelete(); + $table->string('code'); + $table->string('name'); + $table->text('description')->nullable(); + $table->string('status', 30)->default('active'); + $table->decimal('latitude', 10, 7); + $table->decimal('longitude', 10, 7); + $table->text('address')->nullable(); + $table->unsignedInteger('capacity')->nullable(); + $table->date('installed_at')->nullable(); + $table->decimal('diagram_x', 12, 2)->nullable(); + $table->decimal('diagram_y', 12, 2)->nullable(); + $table->json('metadata')->nullable(); + $table->timestamps(); + $table->softDeletes(); + $table->unique(['tenant_id', 'code']); + $table->index(['tenant_id', 'device_type_id', 'status']); + $table->index(['tenant_id', 'latitude', 'longitude']); + }); + + Schema::create('topology_ports', function (Blueprint $table) { + $table->id(); + $table->foreignId('tenant_id')->constrained()->cascadeOnDelete(); + $table->foreignId('node_id')->constrained('topology_nodes')->cascadeOnDelete(); + $table->string('name'); + $table->unsignedInteger('port_number'); + $table->string('port_type', 50)->default('fiber'); + $table->string('direction', 20)->default('bidirectional'); + $table->unsignedInteger('capacity')->default(1); + $table->string('status', 30)->default('available'); + $table->json('metadata')->nullable(); + $table->timestamps(); + $table->unique(['node_id', 'port_number']); + $table->index(['tenant_id', 'status']); + }); + + Schema::create('topology_links', function (Blueprint $table) { + $table->id(); + $table->foreignId('tenant_id')->constrained()->cascadeOnDelete(); + $table->foreignId('source_node_id')->constrained('topology_nodes')->restrictOnDelete(); + $table->foreignId('source_port_id')->nullable()->constrained('topology_ports')->restrictOnDelete(); + $table->foreignId('target_node_id')->constrained('topology_nodes')->restrictOnDelete(); + $table->foreignId('target_port_id')->nullable()->constrained('topology_ports')->restrictOnDelete(); + $table->foreignId('created_by')->nullable()->constrained('users')->nullOnDelete(); + $table->foreignId('updated_by')->nullable()->constrained('users')->nullOnDelete(); + $table->string('code'); + $table->string('name'); + $table->string('link_type', 30)->default('fiber'); + $table->string('fiber_type', 50)->nullable(); + $table->unsignedInteger('core_count')->nullable(); + $table->decimal('cable_length', 12, 2)->nullable(); + $table->decimal('measured_length', 12, 2)->nullable(); + $table->json('path_geojson')->nullable(); + $table->string('status', 30)->default('active'); + $table->date('installed_at')->nullable(); + $table->json('metadata')->nullable(); + $table->timestamps(); + $table->softDeletes(); + $table->unique(['tenant_id', 'code']); + $table->unique(['source_node_id', 'target_node_id', 'source_port_id', 'target_port_id'], 'topology_links_endpoints_unique'); + $table->index(['tenant_id', 'status', 'link_type']); + }); + + Schema::create('topology_connection_rules', function (Blueprint $table) { + $table->id(); + $table->foreignId('tenant_id')->nullable()->constrained()->cascadeOnDelete(); + $table->foreignId('source_device_type_id')->constrained('topology_device_types')->cascadeOnDelete(); + $table->foreignId('target_device_type_id')->constrained('topology_device_types')->cascadeOnDelete(); + $table->boolean('allowed')->default(true); + $table->unsignedInteger('max_connections')->nullable(); + $table->timestamps(); + $table->unique(['tenant_id', 'source_device_type_id', 'target_device_type_id'], 'topology_rules_unique'); + }); + + Schema::create('topology_splitters', function (Blueprint $table) { + $table->id(); + $table->foreignId('tenant_id')->constrained()->cascadeOnDelete(); + $table->foreignId('node_id')->constrained('topology_nodes')->cascadeOnDelete(); + $table->foreignId('input_port_id')->nullable()->constrained('topology_ports')->nullOnDelete(); + $table->string('ratio', 20); + $table->unsignedTinyInteger('level')->default(1); + $table->decimal('insertion_loss_db', 6, 2)->nullable(); + $table->string('status', 30)->default('active'); + $table->json('metadata')->nullable(); + $table->timestamps(); + $table->index(['tenant_id', 'node_id']); + }); + + Schema::create('topology_customer_connections', function (Blueprint $table) { + $table->id(); + $table->foreignId('tenant_id')->constrained()->cascadeOnDelete(); + $table->foreignId('customer_id')->constrained()->cascadeOnDelete(); + $table->foreignId('odp_node_id')->constrained('topology_nodes')->restrictOnDelete(); + $table->foreignId('odp_port_id')->nullable()->constrained('topology_ports')->restrictOnDelete(); + $table->foreignId('drop_cable_link_id')->nullable()->constrained('topology_links')->nullOnDelete(); + $table->timestamp('connected_at')->nullable(); + $table->timestamp('disconnected_at')->nullable(); + $table->string('status', 30)->default('active'); + $table->json('metadata')->nullable(); + $table->timestamps(); + $table->index(['customer_id', 'status']); + $table->index(['tenant_id', 'odp_node_id', 'status']); + }); + } + + public function down(): void + { + Schema::dropIfExists('topology_customer_connections'); + Schema::dropIfExists('topology_splitters'); + Schema::dropIfExists('topology_connection_rules'); + Schema::dropIfExists('topology_links'); + Schema::dropIfExists('topology_ports'); + Schema::dropIfExists('topology_nodes'); + Schema::dropIfExists('topology_device_types'); + } +}; diff --git a/database/migrations/2026_07_29_080000_create_notification_tables.php b/database/migrations/2026_07_29_080000_create_notification_tables.php new file mode 100644 index 0000000..1436a06 --- /dev/null +++ b/database/migrations/2026_07_29_080000_create_notification_tables.php @@ -0,0 +1,260 @@ +id(); + $table->string('code')->unique(); + $table->string('name'); + $table->string('category', 50); + $table->text('description')->nullable(); + $table->json('available_channels'); + $table->json('variables')->nullable(); + $table->boolean('is_system')->default(true); + $table->boolean('enabled')->default(true); + $table->timestamps(); + }); + + Schema::create('notification_channels', function (Blueprint $table) { + $table->id(); + $table->foreignId('tenant_id')->constrained()->cascadeOnDelete(); + $table->string('channel', 30); + $table->string('provider', 50); + $table->string('name'); + $table->boolean('enabled')->default(false); + $table->boolean('is_default')->default(false); + $table->text('credentials')->nullable(); + $table->json('settings')->nullable(); + $table->string('last_health_status', 30)->nullable(); + $table->text('last_health_message')->nullable(); + $table->timestamp('last_health_check_at')->nullable(); + $table->timestamps(); + $table->unique(['tenant_id', 'channel', 'name']); + $table->index(['tenant_id', 'channel', 'enabled']); + }); + + Schema::create('notification_templates', function (Blueprint $table) { + $table->id(); + $table->foreignId('tenant_id')->nullable()->constrained()->cascadeOnDelete(); + $table->foreignId('notification_event_id')->constrained()->cascadeOnDelete(); + $table->foreignId('notification_channel_id')->nullable()->constrained()->nullOnDelete(); + $table->string('channel', 30); + $table->string('name'); + $table->string('subject')->nullable(); + $table->text('body'); + $table->string('provider_template_name')->nullable(); + $table->string('provider_template_id')->nullable(); + $table->string('language', 10)->default('id'); + $table->string('approval_status', 30)->default('not_required'); + $table->boolean('enabled')->default(true); + $table->unsignedInteger('version')->default(1); + $table->json('variables')->nullable(); + $table->timestamps(); + $table->unique(['tenant_id', 'notification_event_id', 'channel', 'language', 'version'], 'notification_templates_version_unique'); + $table->index(['tenant_id', 'channel', 'enabled']); + }); + + Schema::create('notification_preferences', function (Blueprint $table) { + $table->id(); + $table->foreignId('tenant_id')->constrained()->cascadeOnDelete(); + $table->foreignId('notification_event_id')->constrained()->cascadeOnDelete(); + $table->foreignId('notification_channel_id')->nullable()->constrained()->nullOnDelete(); + $table->string('channel', 30); + $table->boolean('enabled')->default(true); + $table->unsignedTinyInteger('priority')->default(1); + $table->unsignedInteger('send_delay_minutes')->default(0); + $table->time('quiet_hours_start')->nullable(); + $table->time('quiet_hours_end')->nullable(); + $table->timestamps(); + $table->unique(['tenant_id', 'notification_event_id', 'channel']); + }); + + Schema::create('customer_contacts', function (Blueprint $table) { + $table->id(); + $table->foreignId('tenant_id')->constrained()->cascadeOnDelete(); + $table->foreignId('customer_id')->constrained()->cascadeOnDelete(); + $table->string('type', 30); + $table->string('value'); + $table->string('label')->nullable(); + $table->boolean('is_primary')->default(false); + $table->boolean('is_verified')->default(false); + $table->boolean('can_receive_transactional')->default(true); + $table->boolean('can_receive_broadcast')->default(false); + $table->timestamp('verified_at')->nullable(); + $table->timestamp('opted_out_at')->nullable(); + $table->timestamps(); + $table->unique(['customer_id', 'type', 'value']); + $table->index(['tenant_id', 'type', 'is_verified']); + }); + + Schema::create('notification_broadcasts', function (Blueprint $table) { + $table->id(); + $table->uuid('uuid')->unique(); + $table->foreignId('tenant_id')->constrained()->cascadeOnDelete(); + $table->foreignId('notification_template_id')->nullable()->constrained()->nullOnDelete(); + $table->foreignId('notification_channel_id')->nullable()->constrained()->nullOnDelete(); + $table->foreignId('created_by')->nullable()->constrained('users')->nullOnDelete(); + $table->foreignId('approved_by')->nullable()->constrained('users')->nullOnDelete(); + $table->string('name'); + $table->string('channel', 30); + $table->string('subject')->nullable(); + $table->text('body')->nullable(); + $table->string('status', 30)->default('draft'); + $table->json('filter_snapshot'); + $table->timestamp('scheduled_at')->nullable(); + $table->unsignedInteger('estimated_recipients')->default(0); + $table->unsignedInteger('processed_recipients')->default(0); + $table->unsignedInteger('success_count')->default(0); + $table->unsignedInteger('failed_count')->default(0); + $table->timestamps(); + $table->index(['tenant_id', 'status', 'scheduled_at']); + }); + + Schema::create('notification_messages', function (Blueprint $table) { + $table->id(); + $table->uuid('uuid')->unique(); + $table->foreignId('tenant_id')->constrained()->cascadeOnDelete(); + $table->foreignId('notification_event_id')->nullable()->constrained()->nullOnDelete(); + $table->foreignId('notification_template_id')->nullable()->constrained()->nullOnDelete(); + $table->foreignId('notification_broadcast_id')->nullable()->constrained()->cascadeOnDelete(); + $table->nullableMorphs('recipient'); + $table->nullableMorphs('subjectable'); + $table->string('subject')->nullable(); + $table->json('payload'); + $table->json('context')->nullable(); + $table->unsignedTinyInteger('priority')->default(5); + $table->timestamp('scheduled_at')->nullable(); + $table->string('status', 30)->default('pending'); + $table->timestamps(); + $table->index(['tenant_id', 'status', 'scheduled_at']); + }); + + Schema::create('notification_deliveries', function (Blueprint $table) { + $table->id(); + $table->uuid('uuid')->unique(); + $table->foreignId('notification_message_id')->constrained()->cascadeOnDelete(); + $table->foreignId('notification_channel_id')->nullable()->constrained()->nullOnDelete(); + $table->string('channel', 30); + $table->string('destination')->nullable(); + $table->string('rendered_subject')->nullable(); + $table->text('rendered_body'); + $table->string('provider_message_id')->nullable()->index(); + $table->string('status', 30)->default('pending'); + $table->unsignedTinyInteger('attempt_count')->default(0); + $table->timestamp('queued_at')->nullable(); + $table->timestamp('sent_at')->nullable(); + $table->timestamp('delivered_at')->nullable(); + $table->timestamp('read_at')->nullable(); + $table->timestamp('failed_at')->nullable(); + $table->timestamp('next_retry_at')->nullable(); + $table->string('error_code')->nullable(); + $table->text('error_message')->nullable(); + $table->json('provider_response')->nullable(); + $table->timestamps(); + $table->index(['channel', 'status', 'next_retry_at']); + }); + + Schema::create('notification_broadcast_recipients', function (Blueprint $table) { + $table->id(); + $table->foreignId('notification_broadcast_id')->constrained()->cascadeOnDelete(); + $table->foreignId('customer_id')->constrained()->cascadeOnDelete(); + $table->foreignId('notification_message_id')->nullable()->constrained()->nullOnDelete(); + $table->string('destination')->nullable(); + $table->string('status', 30)->default('pending'); + $table->text('reason')->nullable(); + $table->timestamps(); + $table->unique(['notification_broadcast_id', 'customer_id']); + }); + + Schema::create('user_notifications', function (Blueprint $table) { + $table->id(); + $table->uuid('uuid')->unique(); + $table->foreignId('tenant_id')->constrained()->cascadeOnDelete(); + $table->foreignId('user_id')->constrained()->cascadeOnDelete(); + $table->foreignId('notification_message_id')->nullable()->constrained()->nullOnDelete(); + $table->string('title'); + $table->text('body'); + $table->string('action_url')->nullable(); + $table->string('icon')->nullable(); + $table->timestamp('read_at')->nullable(); + $table->timestamps(); + $table->index(['user_id', 'read_at', 'created_at']); + }); + + Schema::create('notification_event_occurrences', function (Blueprint $table) { + $table->id(); + $table->foreignId('tenant_id')->constrained()->cascadeOnDelete(); + $table->foreignId('notification_event_id')->constrained()->cascadeOnDelete(); + $table->morphs('subjectable'); + $table->string('occurrence_key'); + $table->timestamp('processed_at')->nullable(); + $table->timestamps(); + $table->unique(['notification_event_id', 'subjectable_type', 'subjectable_id', 'occurrence_key'], 'notification_occurrences_unique'); + }); + + Schema::create('notification_webhook_logs', function (Blueprint $table) { + $table->id(); + $table->foreignId('notification_channel_id')->nullable()->constrained()->nullOnDelete(); + $table->string('provider', 50); + $table->string('event_type')->nullable(); + $table->string('provider_message_id')->nullable()->index(); + $table->boolean('signature_valid')->default(false); + $table->json('payload'); + $table->timestamp('processed_at')->nullable(); + $table->text('error')->nullable(); + $table->timestamps(); + }); + + DB::table('customers') + ->select(['id', 'tenant_id', 'email', 'whatsapp_number']) + ->orderBy('id') + ->chunk(500, function ($customers) { + $rows = []; + foreach ($customers as $customer) { + foreach (['email' => $customer->email, 'whatsapp' => $customer->whatsapp_number] as $type => $value) { + if ($value) { + $rows[] = [ + 'tenant_id' => $customer->tenant_id, + 'customer_id' => $customer->id, + 'type' => $type, + 'value' => $value, + 'label' => 'Kontak utama', + 'is_primary' => true, + 'is_verified' => false, + 'can_receive_transactional' => true, + 'can_receive_broadcast' => true, + 'created_at' => now(), + 'updated_at' => now(), + ]; + } + } + } + if ($rows) { + DB::table('customer_contacts')->insertOrIgnore($rows); + } + }); + } + + public function down(): void + { + Schema::dropIfExists('notification_webhook_logs'); + Schema::dropIfExists('notification_event_occurrences'); + Schema::dropIfExists('user_notifications'); + Schema::dropIfExists('notification_broadcast_recipients'); + Schema::dropIfExists('notification_deliveries'); + Schema::dropIfExists('notification_messages'); + Schema::dropIfExists('notification_broadcasts'); + Schema::dropIfExists('customer_contacts'); + Schema::dropIfExists('notification_preferences'); + Schema::dropIfExists('notification_templates'); + Schema::dropIfExists('notification_channels'); + Schema::dropIfExists('notification_events'); + } +}; diff --git a/database/migrations/2026_07_29_160000_create_payment_gateway_and_wallet_tables.php b/database/migrations/2026_07_29_160000_create_payment_gateway_and_wallet_tables.php new file mode 100644 index 0000000..e518ada --- /dev/null +++ b/database/migrations/2026_07_29_160000_create_payment_gateway_and_wallet_tables.php @@ -0,0 +1,203 @@ +id(); + $table->string('code', 50)->unique(); + $table->string('name'); + $table->string('scope', 20)->default('both'); + $table->json('capabilities')->nullable(); + $table->json('credential_schema')->nullable(); + $table->boolean('enabled')->default(true); + $table->timestamps(); + }); + + Schema::create('payment_gateway_accounts', function (Blueprint $table) { + $table->id(); + $table->uuid('uuid')->unique(); + $table->foreignId('payment_provider_id')->constrained()->restrictOnDelete(); + $table->foreignId('tenant_id')->nullable()->constrained()->cascadeOnDelete(); + $table->string('scope', 20); + $table->string('name'); + $table->string('environment', 20)->default('sandbox'); + $table->text('credentials')->nullable(); + $table->text('webhook_secret')->nullable(); + $table->json('settings')->nullable(); + $table->boolean('enabled')->default(false); + $table->boolean('is_default')->default(false); + $table->string('last_health_status', 30)->nullable(); + $table->text('last_health_message')->nullable(); + $table->timestamp('last_health_check_at')->nullable(); + $table->timestamps(); + $table->index(['scope', 'tenant_id', 'enabled']); + }); + + Schema::create('tenant_payment_settings', function (Blueprint $table) { + $table->id(); + $table->foreignId('tenant_id')->unique()->constrained()->cascadeOnDelete(); + $table->string('payment_mode', 30)->default('disabled'); + $table->foreignId('tenant_gateway_account_id')->nullable()->constrained('payment_gateway_accounts')->nullOnDelete(); + $table->foreignId('platform_gateway_account_id')->nullable()->constrained('payment_gateway_accounts')->nullOnDelete(); + $table->string('fee_bearer', 20)->default('customer'); + $table->unsignedInteger('default_expiry_minutes')->default(1440); + $table->json('enabled_methods')->nullable(); + $table->timestamps(); + }); + + Schema::create('payment_transactions', function (Blueprint $table) { + $table->id(); + $table->uuid('uuid')->unique(); + $table->string('transaction_number')->unique(); + $table->foreignId('tenant_id')->nullable()->constrained()->nullOnDelete(); + $table->foreignId('user_id')->nullable()->constrained()->nullOnDelete(); + $table->foreignId('customer_id')->nullable()->constrained()->nullOnDelete(); + $table->foreignId('invoice_id')->nullable()->constrained()->nullOnDelete(); + $table->foreignId('payment_gateway_account_id')->nullable()->constrained()->nullOnDelete(); + $table->string('purpose', 30); + $table->string('gateway_scope', 20); + $table->string('provider_code', 50); + $table->string('provider_reference')->nullable()->index(); + $table->string('method', 50)->nullable(); + $table->string('currency', 3)->default('IDR'); + $table->decimal('amount', 18, 2); + $table->decimal('provider_fee', 18, 2)->default(0); + $table->decimal('platform_fee', 18, 2)->default(0); + $table->decimal('total_amount', 18, 2); + $table->string('status', 30)->default('created'); + $table->string('provider_status', 50)->nullable(); + $table->string('idempotency_key')->unique(); + $table->text('checkout_url')->nullable(); + $table->text('qr_content')->nullable(); + $table->timestamp('expires_at')->nullable(); + $table->timestamp('paid_at')->nullable(); + $table->timestamp('failed_at')->nullable(); + $table->json('metadata')->nullable(); + $table->timestamps(); + $table->index(['tenant_id', 'status', 'created_at']); + $table->index(['user_id', 'purpose', 'status']); + $table->index(['invoice_id', 'status']); + }); + + Schema::create('payment_attempts', function (Blueprint $table) { + $table->id(); + $table->foreignId('payment_transaction_id')->constrained()->cascadeOnDelete(); + $table->unsignedSmallInteger('attempt_number'); + $table->string('action', 30); + $table->string('status', 30); + $table->json('request_payload')->nullable(); + $table->json('response_payload')->nullable(); + $table->string('error_code')->nullable(); + $table->text('error_message')->nullable(); + $table->unsignedInteger('duration_ms')->nullable(); + $table->timestamps(); + $table->unique(['payment_transaction_id', 'attempt_number', 'action'], 'payment_attempt_unique'); + }); + + Schema::create('payment_webhook_logs', function (Blueprint $table) { + $table->id(); + $table->foreignId('payment_gateway_account_id')->nullable()->constrained()->nullOnDelete(); + $table->foreignId('payment_transaction_id')->nullable()->constrained()->nullOnDelete(); + $table->string('provider_code', 50); + $table->string('provider_event_id')->nullable(); + $table->boolean('signature_valid')->default(false); + $table->json('headers')->nullable(); + $table->json('payload'); + $table->string('status', 30)->default('received'); + $table->text('error_message')->nullable(); + $table->timestamp('processed_at')->nullable(); + $table->timestamps(); + $table->index(['provider_code', 'status', 'created_at']); + $table->unique(['payment_gateway_account_id', 'provider_event_id']); + }); + + Schema::create('wallets', function (Blueprint $table) { + $table->id(); + $table->uuid('uuid')->unique(); + $table->nullableMorphs('owner'); + $table->string('wallet_type', 30); + $table->string('currency', 3)->default('IDR'); + $table->decimal('available_balance', 18, 2)->default(0); + $table->decimal('pending_balance', 18, 2)->default(0); + $table->decimal('locked_balance', 18, 2)->default(0); + $table->string('status', 20)->default('active'); + $table->unsignedBigInteger('version')->default(0); + $table->timestamps(); + $table->unique(['owner_type', 'owner_id', 'wallet_type', 'currency'], 'wallet_owner_type_unique'); + }); + + Schema::create('wallet_transactions', function (Blueprint $table) { + $table->id(); + $table->uuid('uuid')->unique(); + $table->string('transaction_number')->unique(); + $table->string('type', 30); + $table->string('status', 20)->default('pending'); + $table->foreignId('payment_transaction_id')->nullable()->constrained()->nullOnDelete(); + $table->foreignId('initiated_by')->nullable()->constrained('users')->nullOnDelete(); + $table->decimal('amount', 18, 2); + $table->decimal('fee', 18, 2)->default(0); + $table->string('currency', 3)->default('IDR'); + $table->string('idempotency_key')->unique(); + $table->text('description')->nullable(); + $table->json('metadata')->nullable(); + $table->timestamp('completed_at')->nullable(); + $table->timestamps(); + $table->index(['type', 'status', 'created_at']); + }); + + Schema::create('wallet_ledger_entries', function (Blueprint $table) { + $table->id(); + $table->foreignId('wallet_id')->constrained()->restrictOnDelete(); + $table->foreignId('wallet_transaction_id')->constrained()->restrictOnDelete(); + $table->string('entry_type', 10); + $table->string('balance_bucket', 20)->default('available'); + $table->decimal('amount', 18, 2); + $table->decimal('balance_before', 18, 2); + $table->decimal('balance_after', 18, 2); + $table->text('description')->nullable(); + $table->timestamps(); + $table->unique(['wallet_id', 'wallet_transaction_id', 'entry_type', 'balance_bucket'], 'wallet_ledger_unique'); + $table->index(['wallet_id', 'created_at']); + }); + + Schema::create('wallet_withdrawals', function (Blueprint $table) { + $table->id(); + $table->uuid('uuid')->unique(); + $table->foreignId('wallet_id')->constrained()->restrictOnDelete(); + $table->foreignId('wallet_transaction_id')->nullable()->constrained()->nullOnDelete(); + $table->foreignId('requested_by')->nullable()->constrained('users')->nullOnDelete(); + $table->foreignId('approved_by')->nullable()->constrained('users')->nullOnDelete(); + $table->decimal('amount', 18, 2); + $table->decimal('fee', 18, 2)->default(0); + $table->string('bank_code', 30); + $table->string('account_number'); + $table->string('account_name'); + $table->string('status', 30)->default('pending_approval'); + $table->string('provider_reference')->nullable(); + $table->text('rejection_reason')->nullable(); + $table->timestamp('processed_at')->nullable(); + $table->timestamps(); + $table->index(['wallet_id', 'status', 'created_at']); + }); + } + + public function down(): void + { + Schema::dropIfExists('wallet_withdrawals'); + Schema::dropIfExists('wallet_ledger_entries'); + Schema::dropIfExists('wallet_transactions'); + Schema::dropIfExists('wallets'); + Schema::dropIfExists('payment_webhook_logs'); + Schema::dropIfExists('payment_attempts'); + Schema::dropIfExists('payment_transactions'); + Schema::dropIfExists('tenant_payment_settings'); + Schema::dropIfExists('payment_gateway_accounts'); + Schema::dropIfExists('payment_providers'); + } +}; diff --git a/database/seeders/DatabaseSeeder.php b/database/seeders/DatabaseSeeder.php index 81a570e..250e02c 100644 --- a/database/seeders/DatabaseSeeder.php +++ b/database/seeders/DatabaseSeeder.php @@ -17,6 +17,9 @@ class DatabaseSeeder extends Seeder $this->call([ TicketTypeSeeder::class, MenuListSeeder::class, + TopologySeeder::class, + NotificationSeeder::class, + PaymentProviderSeeder::class, ]); User::query()->updateOrCreate( diff --git a/database/seeders/MenuListSeeder.php b/database/seeders/MenuListSeeder.php index 526af97..6e113c2 100644 --- a/database/seeders/MenuListSeeder.php +++ b/database/seeders/MenuListSeeder.php @@ -188,6 +188,28 @@ class MenuListSeeder extends Seeder ); } + $paymentGateway = MenuList::updateOrCreate( + ['slug' => 'payment-gateway'], + [ + 'parent_id' => null, 'name' => 'Payment Gateway', 'url' => null, + 'icon' => 'cil-credit-card', 'component' => null, 'sort_order' => 80, + ] + ); + foreach ([ + ['slug' => 'payment-gateway-settings', 'name' => 'Setting', 'url' => '/payment-gateway/settings', 'sort_order' => 1], + ['slug' => 'payment-gateway-logs', 'name' => 'Log Pembayaran', 'url' => '/payment-gateway/logs', 'sort_order' => 2], + ] as $item) { + MenuList::updateOrCreate(['slug' => $item['slug']], [ + 'parent_id' => $paymentGateway->id, 'name' => $item['name'], 'url' => $item['url'], + 'icon' => 'cil-credit-card', 'component' => null, 'sort_order' => $item['sort_order'], + ]); + } + + MenuList::updateOrCreate(['slug' => 'wallet'], [ + 'parent_id' => null, 'name' => 'Dompet', 'url' => '/wallet', + 'icon' => 'cil-wallet', 'component' => null, 'sort_order' => 81, + ]); + $nas = MenuList::updateOrCreate( ['slug' => 'nas'], [ @@ -220,5 +242,141 @@ class MenuListSeeder extends Seeder ] ); } + + $customers = MenuList::updateOrCreate( + ['slug' => 'customers'], + [ + 'parent_id' => null, + 'name' => 'Customer', + 'url' => null, + 'icon' => 'cil-people', + 'component' => null, + 'sort_order' => 6, + 'is_active' => true, + ] + ); + + foreach ([ + ['slug' => 'customers-orders', 'name' => 'List Order', 'url' => '/customers/orders', 'sort_order' => 1], + ['slug' => 'customers-active', 'name' => 'Aktif', 'url' => '/customers/active', 'sort_order' => 2], + ['slug' => 'customers-inactive', 'name' => 'Tidak Aktif', 'url' => '/customers/inactive', 'sort_order' => 3], + ['slug' => 'customers-unmanaged', 'name' => 'Unmanage', 'url' => '/customers/unmanaged', 'sort_order' => 4], + ['slug' => 'customers-trash', 'name' => 'Sampah', 'url' => '/customers/trash', 'sort_order' => 5], + ] as $item) { + MenuList::updateOrCreate( + ['slug' => $item['slug']], + [ + 'parent_id' => $customers->id, + 'name' => $item['name'], + 'url' => $item['url'], + 'icon' => 'cil-user', + 'component' => null, + 'sort_order' => $item['sort_order'], + 'is_active' => true, + ] + ); + } + + $billing = MenuList::updateOrCreate( + ['slug' => 'billing'], + [ + 'parent_id' => null, + 'name' => 'Tagihan', + 'url' => null, + 'icon' => 'cil-description', + 'component' => null, + 'sort_order' => 7, + 'is_active' => true, + ] + ); + + foreach ([ + ['slug' => 'billing-profiles', 'name' => 'Profile Tagihan', 'url' => '/billing/profiles', 'sort_order' => 1], + ['slug' => 'billing-running', 'name' => 'Tagihan Berjalan', 'url' => '/billing/running', 'sort_order' => 2], + ['slug' => 'billing-overdue', 'name' => 'Tunggakan', 'url' => '/billing/overdue', 'sort_order' => 3], + ['slug' => 'billing-paid', 'name' => 'Lunas', 'url' => '/billing/paid', 'sort_order' => 4], + ] as $item) { + MenuList::updateOrCreate( + ['slug' => $item['slug']], + [ + 'parent_id' => $billing->id, + 'name' => $item['name'], + 'url' => $item['url'], + 'icon' => 'cil-dollar', + 'component' => null, + 'sort_order' => $item['sort_order'], + 'is_active' => true, + ] + ); + } + + $topology = MenuList::updateOrCreate( + ['slug' => 'topology'], + [ + 'parent_id' => null, + 'name' => 'Topologi', + 'url' => null, + 'icon' => 'cil-layers', + 'component' => null, + 'sort_order' => 8, + 'is_active' => true, + ] + ); + + foreach ([ + ['slug' => 'topology-map', 'name' => 'Peta Jaringan', 'url' => '/topology/map', 'icon' => 'cil-location-pin', 'sort_order' => 1], + ['slug' => 'topology-devices', 'name' => 'Perangkat', 'url' => '/topology/devices', 'icon' => 'cil-settings', 'sort_order' => 2], + ['slug' => 'topology-links', 'name' => 'Jalur Kabel', 'url' => '/topology/links', 'icon' => 'cil-layers', 'sort_order' => 3], + ['slug' => 'topology-network', 'name' => 'Topologi Jaringan', 'url' => '/topology/network', 'icon' => 'cil-grid', 'sort_order' => 4], + ['slug' => 'topology-device-types', 'name' => 'Kategori Perangkat', 'url' => '/topology/device-types', 'icon' => 'cil-list', 'sort_order' => 5], + ] as $item) { + MenuList::updateOrCreate( + ['slug' => $item['slug']], + [ + 'parent_id' => $topology->id, + 'name' => $item['name'], + 'url' => $item['url'], + 'icon' => $item['icon'], + 'component' => null, + 'sort_order' => $item['sort_order'], + 'is_active' => true, + ] + ); + } + + $notifications = MenuList::updateOrCreate( + ['slug' => 'notifications-service'], + [ + 'parent_id' => null, + 'name' => 'Notifikasi', + 'url' => null, + 'icon' => 'cil-bell', + 'component' => null, + 'sort_order' => 9, + 'is_active' => true, + ] + ); + + foreach ([ + ['slug' => 'notifications-broadcasts', 'name' => 'Pesan Siaran', 'url' => '/notifications-service/broadcasts', 'icon' => 'cil-speech', 'sort_order' => 1], + ['slug' => 'notifications-system', 'name' => 'Notifikasi Aplikasi', 'url' => '/notifications-service/system', 'icon' => 'cil-bell', 'sort_order' => 2], + ['slug' => 'notifications-wa-official', 'name' => 'WhatsApp Official', 'url' => '/notifications-service/whatsapp-official', 'icon' => 'cil-comment-square', 'sort_order' => 3], + ['slug' => 'notifications-wa-unofficial', 'name' => 'WhatsApp Unofficial', 'url' => '/notifications-service/whatsapp-unofficial', 'icon' => 'cil-comment-square', 'sort_order' => 4], + ['slug' => 'notifications-email', 'name' => 'Email', 'url' => '/notifications-service/email', 'icon' => 'cil-envelope-open', 'sort_order' => 5], + ['slug' => 'notifications-telegram', 'name' => 'Telegram', 'url' => '/notifications-service/telegram', 'icon' => 'cil-arrow-right', 'sort_order' => 6], + ] as $item) { + MenuList::updateOrCreate( + ['slug' => $item['slug']], + [ + 'parent_id' => $notifications->id, + 'name' => $item['name'], + 'url' => $item['url'], + 'icon' => $item['icon'], + 'component' => null, + 'sort_order' => $item['sort_order'], + 'is_active' => true, + ] + ); + } } } diff --git a/database/seeders/NotificationSeeder.php b/database/seeders/NotificationSeeder.php new file mode 100644 index 0000000..586e913 --- /dev/null +++ b/database/seeders/NotificationSeeder.php @@ -0,0 +1,76 @@ + ['Registrasi Customer', 'customer', 'Halo {{customer.name}}, registrasi Anda di {{tenant.name}} berhasil diterima.'], + 'customer.activation' => ['Aktivasi Customer', 'customer', 'Halo {{customer.name}}, layanan Anda telah aktif. ID pelanggan: {{customer.customer_code}}.'], + 'billing.invoice_sent' => ['Kirim Tagihan', 'billing', 'Tagihan {{invoice.invoice_number}} sebesar {{invoice.total}} telah diterbitkan. Batas isolir: {{invoice.isolation_date}}.'], + 'billing.warning' => ['Peringatan Tagihan', 'billing', 'Pengingat: tagihan {{invoice.invoice_number}} sebesar {{invoice.balance}} belum dibayar.'], + 'billing.isolated' => ['Isolir Pelanggan', 'billing', 'Layanan {{customer.customer_code}} memasuki masa isolir karena tagihan belum dibayar.'], + 'voucher_agent.registration' => ['Registrasi Agen Voucher', 'voucher', 'Registrasi agen voucher {{agent.name}} berhasil.'], + 'voucher_agent.balance_added' => ['Tambah Saldo Agen Voucher', 'voucher', 'Saldo agen {{agent.name}} bertambah {{balance.amount}}. Saldo saat ini {{balance.current}}.'], + ]; + $channels = ['system', 'whatsapp_official', 'whatsapp_unofficial', 'email', 'telegram']; + + foreach ($events as $code => [$name, $category, $body]) { + $event = NotificationEvent::updateOrCreate( + ['code' => $code], + [ + 'name' => $name, + 'category' => $category, + 'description' => "Pesan otomatis untuk {$name}", + 'available_channels' => $channels, + 'variables' => $this->variables($body), + 'is_system' => true, + 'enabled' => true, + ], + ); + foreach ($channels as $channel) { + NotificationTemplate::updateOrCreate( + [ + 'tenant_id' => null, + 'notification_event_id' => $event->id, + 'channel' => $channel, + 'language' => 'id', + 'version' => 1, + ], + [ + 'name' => "{$name} - {$this->channelName($channel)}", + 'subject' => $channel === 'email' || $channel === 'system' ? $name : null, + 'body' => $body, + 'approval_status' => $channel === 'whatsapp_official' ? 'pending' : 'not_required', + 'enabled' => true, + 'variables' => $this->variables($body), + ], + ); + } + } + } + + private function variables(string $body): array + { + preg_match_all('/{{\s*([a-zA-Z0-9_.]+)\s*}}/', $body, $matches); + + return array_values(array_unique($matches[1])); + } + + private function channelName(string $channel): string + { + return match ($channel) { + 'system' => 'Aplikasi', + 'whatsapp_official' => 'WhatsApp Official', + 'whatsapp_unofficial' => 'WhatsApp Unofficial', + 'email' => 'Email', + 'telegram' => 'Telegram', + }; + } +} diff --git a/database/seeders/PaymentProviderSeeder.php b/database/seeders/PaymentProviderSeeder.php new file mode 100644 index 0000000..ad915bb --- /dev/null +++ b/database/seeders/PaymentProviderSeeder.php @@ -0,0 +1,27 @@ + $code], [ + 'name' => $name, 'scope' => $scope, 'enabled' => true, + 'capabilities' => ['payment' => true, 'inquiry' => true, 'webhook' => true, 'refund' => in_array($code, ['doku', 'midtrans', 'duitku'])], + 'credential_schema' => collect($credentials)->map(fn ($key) => ['key' => $key, 'label' => str($key)->replace('_', ' ')->title(), 'secret' => ! str_contains($key, 'merchant_id') && ! str_contains($key, 'client_id')])->values(), + ]); + } + } +} diff --git a/database/seeders/TopologySeeder.php b/database/seeders/TopologySeeder.php new file mode 100644 index 0000000..55e8625 --- /dev/null +++ b/database/seeders/TopologySeeder.php @@ -0,0 +1,54 @@ + 'POP', 'name' => 'Point of Presence', 'category' => 'site', 'color' => '#dc3545'], + ['code' => 'OTB', 'name' => 'Optical Termination Box', 'category' => 'termination', 'color' => '#fd7e14'], + ['code' => 'JB', 'name' => 'Joint Box', 'category' => 'closure', 'color' => '#6f42c1'], + ['code' => 'ODC', 'name' => 'Optical Distribution Cabinet', 'category' => 'distribution', 'color' => '#0d6efd'], + ['code' => 'ODP', 'name' => 'Optical Distribution Point', 'category' => 'distribution', 'color' => '#198754'], + ['code' => 'SPLITTER', 'name' => 'Splitter / Ratio', 'category' => 'splitter', 'color' => '#20c997', 'can_have_splitters' => true], + ]; + + $types = collect($definitions)->mapWithKeys(function ($definition) { + $type = TopologyDeviceType::updateOrCreate( + ['tenant_id' => null, 'code' => $definition['code']], + [ + ...$definition, + 'tenant_id' => null, + 'can_have_ports' => true, + 'can_have_splitters' => $definition['can_have_splitters'] ?? false, + 'is_system' => true, + 'icon' => 'cil-location-pin', + 'status' => 'active', + ], + ); + + return [$type->code => $type]; + }); + + foreach ([ + ['POP', 'POP'], ['POP', 'OTB'], ['OTB', 'JB'], ['OTB', 'ODC'], + ['OTB', 'SPLITTER'], ['JB', 'ODC'], ['JB', 'ODP'], ['ODC', 'ODP'], + ['ODC', 'SPLITTER'], ['SPLITTER', 'ODP'], + ] as [$source, $target]) { + TopologyConnectionRule::updateOrCreate( + [ + 'tenant_id' => null, + 'source_device_type_id' => $types[$source]->id, + 'target_device_type_id' => $types[$target]->id, + ], + ['allowed' => true], + ); + } + } +} diff --git a/routes/api.php b/routes/api.php index e84b576..961fc83 100644 --- a/routes/api.php +++ b/routes/api.php @@ -2,29 +2,256 @@ use App\Http\Controllers\Audit\AuditLogController; use App\Http\Controllers\AuthController; +use App\Http\Controllers\Billing\BillingProfileController; +use App\Http\Controllers\Billing\InvoiceController; +use App\Http\Controllers\Customer\CustomerController; use App\Http\Controllers\File\StoredFileController; use App\Http\Controllers\Material\MaterialController; use App\Http\Controllers\Menu\MenuController; use App\Http\Controllers\Menu\MenuGroupController; use App\Http\Controllers\Menu\MenuListController; use App\Http\Controllers\Nas\NasResourceController; +use App\Http\Controllers\Notification\NotificationController; +use App\Http\Controllers\Notification\NotificationWebhookController; +use App\Http\Controllers\Payment\PaymentGatewayController; use App\Http\Controllers\Profile\ProfileController; use App\Http\Controllers\Tenant\TenantController; use App\Http\Controllers\Ticket\TicketController; use App\Http\Controllers\Ticket\TicketIncidentTypeController; use App\Http\Controllers\Ticket\TicketTypeController; use App\Http\Controllers\Ticket\TicketWorkflowController; +use App\Http\Controllers\Topology\TopologyController; use App\Http\Controllers\User\UserController; use App\Http\Controllers\Wilayah\WilayahController; +use App\Http\Controllers\Wallet\WalletController; use Illuminate\Support\Facades\Route; Route::post('/login', [AuthController::class, 'login']); Route::post('/register', [AuthController::class, 'register']); Route::post('/register/verify', [AuthController::class, 'verifyRegistration']); Route::post('/register/resend-code', [AuthController::class, 'resendVerificationCode']); +Route::get('/notification-webhooks/{notificationChannel}', [NotificationWebhookController::class, 'verify']) + ->whereNumber('notificationChannel')->name('notification.webhooks.verify'); +Route::post('/notification-webhooks/{notificationChannel}', [NotificationWebhookController::class, 'handle']) + ->whereNumber('notificationChannel')->name('notification.webhooks.handle'); + +Route::post('/payment-webhooks/{accountUuid}', fn () => response()->json(['message' => 'Provider adapter belum dikonfigurasi.'], 501)) + ->name('payment.webhooks.handle'); Route::middleware(['auth:sanctum', 'tenant.context'])->group(function () { + Route::get('payment-gateway/providers', [PaymentGatewayController::class, 'providers']) + ->middleware('menu.permission:payment-gateway-settings,view')->name('payment-gateway.providers'); + Route::get('payment-gateway/settings', [PaymentGatewayController::class, 'settings']) + ->middleware('menu.permission:payment-gateway-settings,view')->name('payment-gateway.settings.index'); + Route::post('payment-gateway/accounts', [PaymentGatewayController::class, 'storeAccount']) + ->middleware('menu.permission:payment-gateway-settings,create')->name('payment-gateway.accounts.store'); + Route::put('payment-gateway/accounts/{account}', [PaymentGatewayController::class, 'updateAccount']) + ->middleware('menu.permission:payment-gateway-settings,update')->whereNumber('account')->name('payment-gateway.accounts.update'); + Route::put('payment-gateway/tenant-setting', [PaymentGatewayController::class, 'saveSetting']) + ->middleware('menu.permission:payment-gateway-settings,update')->name('payment-gateway.tenant-setting.update'); + Route::get('payment-gateway/transactions', [PaymentGatewayController::class, 'transactions']) + ->middleware('menu.permission:payment-gateway-logs,view')->name('payment-gateway.transactions.index'); + + Route::get('wallets', [WalletController::class, 'index'])->name('wallets.index'); + Route::get('wallets/{wallet}/ledger', [WalletController::class, 'ledger'])->whereNumber('wallet')->name('wallets.ledger'); + Route::post('wallets/{wallet}/transfer', [WalletController::class, 'transfer'])->whereNumber('wallet')->name('wallets.transfer'); + Route::post('wallets/{wallet}/withdraw', [WalletController::class, 'withdraw'])->whereNumber('wallet')->name('wallets.withdraw'); + + Route::get('notifications/events', [NotificationController::class, 'events'])->name('notifications.events'); + Route::get('notifications/preferences', [NotificationController::class, 'preferences']) + ->middleware('menu.permission:notifications-system,view')->name('notifications.preferences.index'); + Route::put('notifications/preferences', [NotificationController::class, 'savePreferences']) + ->middleware('menu.permission:notifications-system,update')->name('notifications.preferences.update'); + Route::get('notifications/broadcasts', [NotificationController::class, 'broadcasts']) + ->middleware('menu.permission:notifications-broadcasts,view')->name('notifications.broadcasts.index'); + Route::post('notifications/broadcasts/preview', [NotificationController::class, 'previewBroadcast']) + ->middleware('menu.permission:notifications-broadcasts,create')->name('notifications.broadcasts.preview'); + Route::post('notifications/broadcasts', [NotificationController::class, 'storeBroadcast']) + ->middleware('menu.permission:notifications-broadcasts,create')->name('notifications.broadcasts.store'); + Route::get('notifications/broadcasts/{notificationBroadcast}', [NotificationController::class, 'showBroadcast']) + ->middleware('menu.permission:notifications-broadcasts,view')->whereNumber('notificationBroadcast')->name('notifications.broadcasts.show'); + Route::post('notifications/broadcasts/{notificationBroadcast}/approve', [NotificationController::class, 'approveBroadcast']) + ->middleware('menu.permission:notifications-broadcasts,approve')->whereNumber('notificationBroadcast')->name('notifications.broadcasts.approve'); + Route::get('me/notifications', [NotificationController::class, 'myNotifications'])->name('me.notifications.index'); + Route::put('me/notifications/{uuid}/read', [NotificationController::class, 'markRead'])->name('me.notifications.read'); + Route::get('customers/{customer}/notification-contacts', [NotificationController::class, 'contacts']) + ->middleware('menu.permission:customers-active,view')->whereNumber('customer')->name('customers.notification-contacts.index'); + Route::post('customers/{customer}/notification-contacts', [NotificationController::class, 'storeContact']) + ->middleware('menu.permission:customers-active,update')->whereNumber('customer')->name('customers.notification-contacts.store'); + Route::put('customers/{customer}/notification-contacts/{contact}', [NotificationController::class, 'updateContact']) + ->middleware('menu.permission:customers-active,update')->whereNumber('customer')->whereNumber('contact')->name('customers.notification-contacts.update'); + Route::delete('customers/{customer}/notification-contacts/{contact}', [NotificationController::class, 'destroyContact']) + ->middleware('menu.permission:customers-active,update')->whereNumber('customer')->whereNumber('contact')->name('customers.notification-contacts.destroy'); + + foreach ([ + 'system' => ['channel' => 'system', 'menu' => 'notifications-system'], + 'whatsapp-official' => ['channel' => 'whatsapp_official', 'menu' => 'notifications-wa-official'], + 'whatsapp-unofficial' => ['channel' => 'whatsapp_unofficial', 'menu' => 'notifications-wa-unofficial'], + 'email' => ['channel' => 'email', 'menu' => 'notifications-email'], + 'telegram' => ['channel' => 'telegram', 'menu' => 'notifications-telegram'], + ] as $path => $notification) { + Route::prefix("notifications/channels/{$path}") + ->middleware("menu.resource:{$notification['menu']}") + ->controller(NotificationController::class) + ->group(function () use ($path, $notification) { + Route::get('/settings', 'channels')->defaults('notification_channel', $notification['channel'])->name("notifications.{$path}.settings.index"); + Route::post('/settings', 'storeChannel')->defaults('notification_channel', $notification['channel'])->name("notifications.{$path}.settings.store"); + Route::put('/settings/{notificationChannel}', 'updateChannel')->defaults('notification_channel', $notification['channel'])->whereNumber('notificationChannel')->name("notifications.{$path}.settings.update"); + Route::post('/settings/{notificationChannel}/test', 'testChannel')->defaults('notification_channel', $notification['channel'])->whereNumber('notificationChannel')->name("notifications.{$path}.settings.test"); + Route::get('/templates', 'templates')->defaults('notification_channel', $notification['channel'])->name("notifications.{$path}.templates.index"); + Route::post('/templates', 'storeTemplate')->defaults('notification_channel', $notification['channel'])->name("notifications.{$path}.templates.store"); + Route::put('/templates/{notificationTemplate}', 'updateTemplate')->defaults('notification_channel', $notification['channel'])->whereNumber('notificationTemplate')->name("notifications.{$path}.templates.update"); + Route::get('/logs', 'deliveries')->defaults('notification_channel', $notification['channel'])->name("notifications.{$path}.logs.index"); + }); + } + + Route::get('topology/options', [TopologyController::class, 'options']) + ->name('topology.options'); + Route::get('topology/map', [TopologyController::class, 'map']) + ->middleware('menu.permission:topology-map,view') + ->name('topology.map'); + Route::post('topology/customer-connections', [TopologyController::class, 'connectCustomer']) + ->middleware('menu.permission:topology-devices,update') + ->name('topology.customer-connections.store'); + Route::get('topology/customers/{customer}/path', [TopologyController::class, 'customerPath']) + ->middleware('menu.permission:topology-network,view') + ->whereNumber('customer')->name('topology.customers.path'); + Route::get('topology/nodes/{node}/ports', [TopologyController::class, 'ports']) + ->middleware('menu.permission:topology-devices,view') + ->whereNumber('node')->name('topology.nodes.ports.index'); + Route::post('topology/nodes/{node}/ports', [TopologyController::class, 'storePort']) + ->defaults('topology_resource', 'port') + ->middleware('menu.permission:topology-devices,create') + ->whereNumber('node')->name('topology.nodes.ports.store'); + Route::put('topology/ports/{port}', [TopologyController::class, 'updatePort']) + ->defaults('topology_resource', 'port') + ->middleware('menu.permission:topology-devices,update') + ->whereNumber('port')->name('topology.ports.update'); + Route::delete('topology/ports/{port}', [TopologyController::class, 'destroyPort']) + ->middleware('menu.permission:topology-devices,delete') + ->whereNumber('port')->name('topology.ports.destroy'); + Route::get('topology/nodes/{node}/{direction}', [TopologyController::class, 'trace']) + ->middleware('menu.permission:topology-network,view') + ->whereNumber('node')->whereIn('direction', ['upstream', 'downstream']) + ->name('topology.nodes.trace'); + + foreach ([ + 'device-types' => ['resource' => 'device-type', 'menu' => 'topology-device-types'], + 'nodes' => ['resource' => 'node', 'menu' => 'topology-devices'], + 'links' => ['resource' => 'link', 'menu' => 'topology-links'], + ] as $path => $topology) { + Route::prefix("topology/{$path}") + ->middleware("menu.resource:{$topology['menu']}") + ->controller(TopologyController::class) + ->group(function () use ($path, $topology) { + Route::get('/', 'index')->defaults('topology_resource', $topology['resource'])->name("topology.{$path}.index"); + Route::post('/', 'store')->defaults('topology_resource', $topology['resource'])->name("topology.{$path}.store"); + Route::get('/{id}', 'show')->defaults('topology_resource', $topology['resource'])->whereNumber('id')->name("topology.{$path}.show"); + Route::put('/{id}', 'update')->defaults('topology_resource', $topology['resource'])->whereNumber('id')->name("topology.{$path}.update"); + Route::patch('/{id}', 'update')->defaults('topology_resource', $topology['resource'])->whereNumber('id')->name("topology.{$path}.patch"); + Route::delete('/{id}', 'destroy')->defaults('topology_resource', $topology['resource'])->whereNumber('id')->name("topology.{$path}.destroy"); + }); + } + + Route::prefix('billing/profiles') + ->middleware('menu.resource:billing-profiles') + ->controller(BillingProfileController::class) + ->group(function () { + Route::get('/', 'index')->name('billing.profiles.index'); + Route::post('/', 'store')->name('billing.profiles.store'); + Route::get('/{id}', 'show')->name('billing.profiles.show')->whereNumber('id'); + Route::put('/{id}', 'update')->name('billing.profiles.update')->whereNumber('id'); + Route::patch('/{id}', 'update')->name('billing.profiles.patch')->whereNumber('id'); + Route::delete('/{id}', 'destroy')->name('billing.profiles.destroy')->whereNumber('id'); + }); + + foreach ([ + 'running' => 'billing-running', + 'overdue' => 'billing-overdue', + 'paid' => 'billing-paid', + ] as $scope => $menuSlug) { + Route::prefix("billing/invoices/{$scope}") + ->middleware("menu.resource:{$menuSlug}") + ->controller(InvoiceController::class) + ->group(function () use ($scope) { + Route::get('/', 'index')->name("billing.invoices.{$scope}.index")->defaults('invoice_scope', $scope); + Route::get('/{id}', 'show')->name("billing.invoices.{$scope}.show")->whereNumber('id')->defaults('invoice_scope', $scope); + if ($scope !== 'paid') { + Route::post('/{id}/pay', 'pay')->name("billing.invoices.{$scope}.pay")->whereNumber('id'); + } + }); + } + Route::get('billing/invoice-options', [InvoiceController::class, 'options']) + ->name('billing.invoices.options') + ->middleware('menu.permission:billing-running,view'); + Route::post('billing/invoices', [InvoiceController::class, 'store']) + ->name('billing.invoices.store') + ->middleware('menu.permission:billing-running,create'); + Route::put('billing/invoices/{id}', [InvoiceController::class, 'update']) + ->name('billing.invoices.update')->whereNumber('id') + ->middleware('menu.permission:billing-running,update'); + Route::patch('billing/invoices/{id}', [InvoiceController::class, 'update']) + ->name('billing.invoices.patch')->whereNumber('id') + ->middleware('menu.permission:billing-running,update'); + Route::post('billing/invoices/{id}/cancel', [InvoiceController::class, 'cancel']) + ->name('billing.invoices.cancel')->whereNumber('id') + ->middleware('menu.permission:billing-running,delete'); + + Route::get('customers/options', [CustomerController::class, 'options']) + ->name('customers.options') + ->middleware('menu.permission:customers-orders,view'); + + foreach ([ + 'orders' => 'customers-orders', + 'active' => 'customers-active', + 'inactive' => 'customers-inactive', + 'unmanaged' => 'customers-unmanaged', + 'trash' => 'customers-trash', + ] as $scope => $menuSlug) { + Route::get("customers/{$scope}", [CustomerController::class, 'index']) + ->name("customers.{$scope}.index") + ->defaults('customer_scope', $scope) + ->middleware("menu.permission:{$menuSlug},view"); + Route::get("customers/{$scope}/{id}", [CustomerController::class, 'show']) + ->name("customers.{$scope}.show") + ->whereNumber('id') + ->defaults('customer_scope', $scope) + ->middleware("menu.permission:{$menuSlug},view"); + } + + Route::post('customers', [CustomerController::class, 'store']) + ->name('customers.store') + ->middleware('menu.permission:customers-orders,create'); + Route::put('customers/{id}', [CustomerController::class, 'update']) + ->name('customers.update') + ->whereNumber('id') + ->middleware('menu.permission:customers-orders,update'); + Route::patch('customers/{id}', [CustomerController::class, 'update']) + ->name('customers.patch') + ->whereNumber('id') + ->middleware('menu.permission:customers-orders,update'); + Route::post('customers/{id}/activate', [CustomerController::class, 'activate']) + ->name('customers.activate') + ->whereNumber('id') + ->middleware('menu.permission:customers-orders,update'); + Route::post('customers/{id}/deactivate', [CustomerController::class, 'deactivate']) + ->name('customers.deactivate') + ->whereNumber('id') + ->middleware('menu.permission:customers-active,update'); + Route::delete('customers/{id}', [CustomerController::class, 'destroy']) + ->name('customers.destroy') + ->whereNumber('id') + ->middleware('menu.permission:customers-inactive,delete'); + Route::post('customers/{id}/restore', [CustomerController::class, 'restore']) + ->name('customers.restore') + ->whereNumber('id') + ->middleware('menu.permission:customers-trash,update'); + Route::delete('customers/{id}/force', [CustomerController::class, 'forceDelete']) + ->name('customers.force-delete') + ->whereNumber('id') + ->middleware('menu.permission:customers-trash,delete'); + foreach ([ 'mikrotiks' => ['resource' => 'mikrotik', 'menu' => 'nas-mikrotik'], 'package-profiles' => ['resource' => 'package-profile', 'menu' => 'nas-package-profiles'], diff --git a/routes/console.php b/routes/console.php index 3c9adf1..685f083 100644 --- a/routes/console.php +++ b/routes/console.php @@ -2,7 +2,20 @@ use Illuminate\Foundation\Inspiring; use Illuminate\Support\Facades\Artisan; +use Illuminate\Support\Facades\Schedule; Artisan::command('inspire', function () { $this->comment(Inspiring::quote()); })->purpose('Display an inspiring quote'); + +Schedule::command('billing:process-schedules') + ->dailyAt('00:05') + ->timezone(config('app.timezone')) + ->withoutOverlapping() + ->onOneServer(); + +Schedule::command('notifications:prune') + ->dailyAt('02:15') + ->timezone(config('app.timezone')) + ->withoutOverlapping() + ->onOneServer(); diff --git a/tests/Feature/BillingWorkflowTest.php b/tests/Feature/BillingWorkflowTest.php new file mode 100644 index 0000000..47558b8 --- /dev/null +++ b/tests/Feature/BillingWorkflowTest.php @@ -0,0 +1,78 @@ + 'BILL-A', 'tenant_name' => 'Billing Tenant']); + $owner = User::factory()->create(['access_level' => 'tenant_owner']); + $owner->userTenants()->create(['tenant_id' => $tenant->id, 'is_default' => true]); + $profile = app(BillingProfileService::class)->create([ + 'name' => 'Bulanan Reguler', + 'invoice_day' => 1, + 'send_day' => 2, + 'warning_day' => 10, + 'isolation_day' => 15, + 'tax_type' => 'exclusive', + 'tax_rate' => 11, + ], $owner, $tenant->id); + $customer = Customer::create([ + 'tenant_id' => $tenant->id, + 'customer_code' => 'CUST-BILL', + 'name' => 'Customer Billing', + 'status' => 'active', + 'billing_profile_id' => $profile->id, + ]); + $service = app(InvoiceService::class); + $invoice = $service->create([ + 'customer_id' => $customer->id, + 'period_start' => now()->startOfMonth()->toDateString(), + 'period_end' => now()->endOfMonth()->toDateString(), + 'issue_date' => now()->subDays(20)->toDateString(), + 'send_date' => now()->subDays(18)->toDateString(), + 'warning_date' => now()->subDays(10)->toDateString(), + 'isolation_date' => now()->subDay()->toDateString(), + 'subtotal' => 100000, + ], $owner, $tenant->id); + + $this->assertSame('overdue', $invoice->status); + $this->assertSame('111000.00', $invoice->total); + $this->assertCount(1, $service->list('overdue', ['per_page' => 10], $owner, $tenant->id)); + + $paid = $service->pay($invoice, ['amount' => 111000, 'payment_method' => 'cash'], $owner); + + $this->assertSame('paid', $paid->status); + $this->assertSame('paid', $paid->payment_status); + $this->assertSame('0.00', $paid->balance); + $this->assertCount(1, $service->list('paid', ['per_page' => 10], $owner, $tenant->id)); + } + + public function test_profile_and_invoice_lists_are_tenant_scoped(): void + { + $tenantA = Tenant::create(['tenant_code' => 'BILL-LA', 'tenant_name' => 'Billing A']); + $tenantB = Tenant::create(['tenant_code' => 'BILL-LB', 'tenant_name' => 'Billing B']); + $owner = User::factory()->create(['access_level' => 'tenant_owner']); + $owner->userTenants()->create(['tenant_id' => $tenantA->id, 'is_default' => true]); + $master = User::factory()->create(['is_master' => true, 'access_level' => 'master_admin']); + $service = app(BillingProfileService::class); + $base = ['invoice_day' => 1, 'send_day' => 2, 'warning_day' => 10, 'isolation_day' => 15, 'tax_type' => 'non_tax']; + $service->create([...$base, 'name' => 'Profile A'], $owner, $tenantA->id); + $service->create([...$base, 'name' => 'Profile B', 'tenant_id' => $tenantB->id], $master, null); + + $result = $service->list(['per_page' => 100], $owner, $tenantA->id); + + $this->assertSame(['Profile A'], $result->getCollection()->pluck('name')->all()); + } +} diff --git a/tests/Feature/CustomerWorkflowTest.php b/tests/Feature/CustomerWorkflowTest.php new file mode 100644 index 0000000..77f6bc9 --- /dev/null +++ b/tests/Feature/CustomerWorkflowTest.php @@ -0,0 +1,98 @@ + 'CUST-A', 'tenant_name' => 'Customer Tenant']); + $owner = User::factory()->create(['access_level' => 'tenant_owner']); + $owner->userTenants()->create(['tenant_id' => $tenant->id, 'is_default' => true]); + $provinsi = WilayahProvinsi::create(['kode' => '01', 'nama' => 'Provinsi']); + $kabupaten = WilayahKabupaten::create(['provinsi_id' => $provinsi->id, 'kode' => '0101', 'nama' => 'Kabupaten']); + $kecamatan = WilayahKecamatan::create(['kabupaten_id' => $kabupaten->id, 'kode' => '010101', 'nama' => 'Kecamatan']); + $desa = WilayahDesa::create(['kecamatan_id' => $kecamatan->id, 'kode' => '01010101', 'nama' => 'Desa']); + $profile = NasPackageProfile::create([ + 'tenant_id' => $tenant->id, + 'name' => '10 Mbps', + 'service_type' => 'ppp', + ]); + $nas = NasMikrotik::create([ + 'tenant_id' => $tenant->id, + 'name' => 'Router Customer', + 'connection_type' => 'api', + 'host' => '10.0.0.1', + ]); + $billingProfile = BillingProfile::create([ + 'tenant_id' => $tenant->id, + 'name' => 'Tagihan Customer', + 'invoice_day' => 1, + 'send_day' => 2, + 'warning_day' => 10, + 'isolation_day' => 15, + 'tax_type' => 'non_tax', + ]); + $service = app(CustomerService::class); + + $customer = $service->create([ + 'name' => 'Customer Satu', + 'installation_address' => 'Jalan Pengujian', + 'provinsi_id' => $provinsi->id, + 'kabupaten_id' => $kabupaten->id, + 'kecamatan_id' => $kecamatan->id, + 'desa_id' => $desa->id, + ], $owner, $tenant->id); + + $this->assertSame('order', $customer->status); + + $customer = $service->activate($customer, [ + 'package_profile_id' => $profile->id, + 'nas_mikrotik_id' => $nas->id, + 'billing_profile_id' => $billingProfile->id, + ]); + $this->assertSame('active', $customer->status); + $this->assertNotNull($customer->activated_at); + + $customer = $service->deactivate($customer); + $this->assertSame('inactive', $customer->status); + + $service->trash($customer); + $this->assertNotNull($customer->fresh()->deleted_at); + + $restored = $service->restore($service->find($customer->id, true)); + $this->assertNull($restored->deleted_at); + $this->assertSame('inactive', $restored->status); + } + + public function test_customer_list_is_isolated_by_active_tenant(): void + { + $tenantA = Tenant::create(['tenant_code' => 'CUST-LA', 'tenant_name' => 'Tenant A']); + $tenantB = Tenant::create(['tenant_code' => 'CUST-LB', 'tenant_name' => 'Tenant B']); + $owner = User::factory()->create(['access_level' => 'tenant_owner']); + $owner->userTenants()->create(['tenant_id' => $tenantA->id, 'is_default' => true]); + $service = app(CustomerService::class); + $service->create(['name' => 'Customer A'], $owner, $tenantA->id); + $master = User::factory()->create(['is_master' => true, 'access_level' => 'master_admin']); + $service->create(['tenant_id' => $tenantB->id, 'name' => 'Customer B'], $master, null); + + $result = $service->list('orders', ['per_page' => 100], $owner, $tenantA->id); + + $this->assertSame(['Customer A'], $result->getCollection()->pluck('name')->all()); + } +}