with('createdBy.userProfile', 'media') ->latest() ->get() ->map(fn (Expense $expense) => $this->formatExpense($expense)); } 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 { return DB::transaction(function () use ($data) { $cashAccount = CashAccount::firstOrFail(); if ($cashAccount->balance < $data['amount']) { throw ValidationException::withMessages([ 'amount' => 'Saldo tidak mencukupi.', ]); } $newBalance = $cashAccount->balance - $data['amount']; $cashAccount->update(['balance' => $newBalance]); $cashTransaction = CashTransaction::create([ 'cash_account_id' => $cashAccount->id, 'created_by_id' => auth()->id(), 'amount' => $data['amount'], 'balance_after' => $newBalance, 'type' => CashTransactionType::EXPENSE, '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'], $data['file_size'] ?? null, $data['file_mime_type'] ?? null); } return $expense; }); } public function update(Expense $expense, array $data): Expense { return 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'], $data['file_size'] ?? null, $data['file_mime_type'] ?? null); } } } return $expense; }); } public function delete(Expense $expense): bool { return DB::transaction(function () use ($expense) { $cashAccount = CashAccount::firstOrFail(); $newBalance = $cashAccount->balance + $expense->amount; $cashAccount->update(['balance' => $newBalance]); // Invalidate receipt cache $media = $expense->getFirstMedia('receipts'); if ($media) { Cache::forget("expense_receipt_{$media->id}"); } $expense->clearMediaCollection('receipts'); return $expense->delete(); }); } private function registerMedia(Expense $expense, string $s3Key, ?int $fileSize = null, ?string $mimeType = null): void { $fileName = pathinfo($s3Key, PATHINFO_BASENAME); $name = pathinfo($s3Key, PATHINFO_FILENAME); Media::create([ 'model_type' => Expense::class, 'model_id' => $expense->id, 'uuid' => Str::uuid(), 'collection_name' => 'receipts', 'name' => $name, 'file_name' => $s3Key, 'mime_type' => $mimeType ?? 'image/jpeg', 'disk' => 's3', 'conversions_disk' => 's3', 'size' => $fileSize ?? 0, 'manipulations' => [], 'custom_properties' => [], 'generated_conversions' => ['thumb' => true], 'responsive_images' => [], 'order_column' => 1, ]); } }