with('createdBy.userProfile', 'media') ->latest() ->get() ->map(fn (Expense $expense) => $this->formatExpense($expense)); } public function paginated(int $perPage = 25, string $search = '', string $sort = 'created_at', string $direction = 'desc', array $filters = []): LengthAwarePaginator { $paginator = Expense::query() ->select(['id', 'created_by_id', 'amount', 'description', 'created_at']) ->with('createdBy.userProfile', 'media') ->when($search, fn ($q) => $q->where('description', 'like', "%{$search}%")) ->orderBy($sort, $direction) ->paginate($perPage); $paginator->getCollection()->transform(fn (Expense $expense) => $this->formatExpense($expense)); return $paginator; } private function formatExpense(Expense $expense): array { $media = $expense->getFirstMedia('receipts'); if (! $media) { return $expense->toArray() + [ 'receipt_key' => null, 'receipt_url' => null, ]; } $s3Key = $media->file_name; // Handle old data where file_name is just the filename, not full path if (str_starts_with($s3Key, '/') || ! str_contains($s3Key, '/')) { $s3Key = $media->getPath(); } $cacheKey = "expense_receipt_{$media->id}"; $receiptUrl = Cache::remember($cacheKey, now()->addMinutes(55), function () use ($s3Key) { return $this->s3Service->getTemporaryUrl($s3Key); }); return $expense->toArray() + [ 'receipt_key' => $s3Key, 'receipt_url' => $receiptUrl, ]; } public function create(array $data): Expense { $expense = DB::transaction(function () use ($data) { $cashTransaction = $this->debitCash( amount: $data['amount'], description: $data['description'], ); $expense = Expense::create([ 'cash_transaction_id' => $cashTransaction->id, 'created_by_id' => auth()->id(), 'amount' => $data['amount'], 'description' => $data['description'], ]); if (! empty($data['receipt_key'])) { $this->registerMedia($expense, $data['receipt_key'], 'receipts', ['thumb' => true], $data['file_size'] ?? null, $data['file_mime_type'] ?? null); } return $expense; }); NotificationService::notify( roles: ['Owner', 'Developer', 'Direktur', 'Admin Toko'], title: 'Pengeluaran Baru', body: 'Pengeluaran sebesar Rp '.number_format($data['amount'], 0, ',', '.').' berhasil dicatat'.' oleh '.auth()->user()->full_name.'.', url: route('admin.finance.expenses.index'), ); return $expense; } public function update(Expense $expense, array $data): Expense { $expense = DB::transaction(function () use ($expense, $data) { $cashAccount = CashAccount::firstOrFail(); $cashTransaction = $expense->cashTransaction; $oldAmount = $expense->amount; $newAmount = $data['amount']; $difference = $newAmount - $oldAmount; if ($difference > 0 && $cashAccount->balance < $difference) { throw ValidationException::withMessages([ 'amount' => 'Saldo tidak mencukupi.', ]); } $newBalance = $cashAccount->balance - $difference; $cashAccount->update(['balance' => $newBalance]); $cashTransaction->update([ 'amount' => $newAmount, 'balance_after' => $newBalance, 'description' => $data['description'], ]); $expense->update([ 'amount' => $newAmount, 'description' => $data['description'], ]); if (array_key_exists('receipt_key', $data)) { $currentMedia = $expense->getFirstMedia('receipts'); $currentKey = $currentMedia?->file_name; // Normalize: if file_name is just a filename (old data), use getPath() if ($currentMedia && ! str_contains($currentKey, '/')) { $currentKey = $currentMedia->getPath(); } if ($data['receipt_key'] !== $currentKey) { // Invalidate old receipt cache if ($currentMedia) { Cache::forget("expense_receipt_{$currentMedia->id}"); } $expense->clearMediaCollection('receipts'); if (! empty($data['receipt_key'])) { $this->registerMedia($expense, $data['receipt_key'], 'receipts', ['thumb' => true], $data['file_size'] ?? null, $data['file_mime_type'] ?? null); } } } return $expense; }); NotificationService::notify( roles: ['Owner', 'Developer', 'Direktur', 'Admin Toko'], title: 'Pengeluaran Diperbarui', body: 'Pengeluaran sebesar Rp '.number_format($expense->amount, 0, ',', '.').' berhasil diperbarui'.' oleh '.auth()->user()->full_name.'.', url: route('admin.finance.expenses.index'), ); return $expense; } public function delete(Expense $expense): bool { return DB::transaction(function () use ($expense) { $this->creditCash( amount: $expense->amount, description: 'Pembatalan pengeluaran: '.$expense->description, type: CashTransactionType::DEPOSIT, ); // Invalidate receipt cache $media = $expense->getFirstMedia('receipts'); if ($media) { Cache::forget("expense_receipt_{$media->id}"); } $expense->clearMediaCollection('receipts'); $deleted = $expense->delete(); if ($deleted) { NotificationService::notify( roles: ['Owner', 'Developer', 'Direktur', 'Admin Toko'], title: 'Pengeluaran Dihapus', body: 'Pengeluaran sebesar Rp '.number_format($expense->amount, 0, ',', '.').' berhasil dihapus'.' oleh '.auth()->user()->full_name.'.', url: route('admin.finance.expenses.index'), ); } return $deleted; }); } }