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); } }