find($batchId); if (! $batch) { throw new \RuntimeException('Requested batch was not found.'); } $receiptRows = ! empty($receiptIds) ? $this->fetchBatchReceiptRows($batchId, $receiptIds) : []; $checkRows = []; if (! empty($checkIds)) { $checkRows = ReimbursementBatchAdminFile::query() ->select('id as file_id', 'admin_id', 'filename', 'original_filename', 'uploaded_at') ->where('batch_id', $batchId) ->whereIn('id', $checkIds) ->get() ->toArray(); $adminLookup = []; foreach ($this->recipients->adminRoster() as $admin) { $adminLookup[(int) ($admin['id'] ?? 0)] = trim((string) (($admin['firstname'] ?? '').' '.($admin['lastname'] ?? ''))); } foreach ($checkRows as &$row) { $aid = (int) ($row['admin_id'] ?? 0); $row['admin'] = $adminLookup[$aid] ?? ($aid > 0 ? 'Admin #'.$aid : 'Admin'); } unset($row); } $tempDir = storage_path('tmp'); if (! is_dir($tempDir)) { mkdir($tempDir, 0755, true); } $cleanupPaths = []; $batchLabel = trim((string) ($batch->title ?? '')) ?: ('Batch #'.((int) ($batch->yearly_batch_number ?? $batchId))); $batchFileLabel = $this->sanitizeAttachmentComponent($batchLabel); $csvPath = tempnam($tempDir, 'batch_csv_'); $cleanupPaths[] = $csvPath; $csvHandle = fopen($csvPath, 'w'); if (! $csvHandle) { if (is_file($csvPath)) { @unlink($csvPath); } throw new \RuntimeException('Unable to create the summary file.'); } $batchClosedDate = $this->formatCsvDate($batch->closed_at ?? null); $this->writeBatchCsvMetadata($csvHandle, $batchLabel, $batch->toArray()); $this->writeBatchCsvSummary($csvHandle, $receiptRows, $batchClosedDate); fclose($csvHandle); $csvAttachmentName = $batchFileLabel.'_summary.csv'; $receiptsArchivePath = null; $receiptZipCreated = false; if (! empty($receiptRows)) { $archivePath = $tempDir.'/batch_receipts_'.$batchId.'_'.date('Ymd_His').'_'.bin2hex(random_bytes(4)).'.zip'; $zip = new ZipArchive; $added = 0; $usedNames = []; if ($zip->open($archivePath, ZipArchive::CREATE | ZipArchive::OVERWRITE) === true) { foreach ($receiptRows as $row) { $sourcePath = storage_path('uploads/receipts/'.basename((string) ($row['receipt_filename'] ?? ''))); if (! is_file($sourcePath)) { continue; } $firstName = $row['purchaser_firstname'] ?? ''; if (trim($firstName) === '' && ! empty($row['purchaser_lastname'] ?? '')) { $firstName = $row['purchaser_lastname']; } $firstName = $this->sanitizeAttachmentComponent($firstName); $vendorPart = $this->sanitizeAttachmentComponent($row['retailor'] ?? ''); $datePart = $this->formatAttachmentDate($row['purchase_date'] ?? ''); $extension = pathinfo($sourcePath, PATHINFO_EXTENSION); $extPart = $extension !== '' ? ('.'.strtolower($extension)) : ''; $baseName = $firstName.'_'.$vendorPart.'_'.$datePart; $entryName = $this->uniqueZipEntryName($baseName, $extPart, $usedNames); if ($zip->addFile($sourcePath, $entryName)) { $added++; } } $zip->close(); } if ($added > 0 && is_file($archivePath)) { $receiptsArchivePath = $archivePath; $cleanupPaths[] = $archivePath; $receiptZipCreated = true; } elseif (is_file($archivePath)) { @unlink($archivePath); } } $checksArchivePath = null; $checksAdded = 0; if (! empty($checkRows)) { $archivePath = $tempDir.'/batch_checks_'.$batchId.'_'.date('Ymd_His').'_'.bin2hex(random_bytes(4)).'.zip'; $zip = new ZipArchive; $usedNames = []; if ($zip->open($archivePath, ZipArchive::CREATE | ZipArchive::OVERWRITE) === true) { foreach ($checkRows as $row) { $sourcePath = storage_path('uploads/reimbursements/'.basename((string) ($row['filename'] ?? ''))); if (! is_file($sourcePath)) { continue; } $adminLabel = $row['admin'] ?? 'Admin'; $firstToken = strtok($adminLabel, ' '); $namePart = $this->sanitizeAttachmentComponent($firstToken ?: $adminLabel); $extension = pathinfo($sourcePath, PATHINFO_EXTENSION); $extPart = $extension !== '' ? ('.'.strtolower($extension)) : ''; $entryName = $this->uniqueZipEntryName($namePart, $extPart, $usedNames); if ($zip->addFile($sourcePath, $entryName)) { $checksAdded++; } } $zip->close(); } if ($checksAdded > 0 && is_file($archivePath)) { $checksArchivePath = $archivePath; $cleanupPaths[] = $archivePath; } elseif (is_file($archivePath)) { @unlink($archivePath); } } $attachmentSummary = ['CSV Summary']; if ($receiptZipCreated) { $attachmentSummary[] = 'Receipt Files'; } if ($checksArchivePath) { $attachmentSummary[] = 'Check Scans'; } $subject = 'Reimbursement Batch Export: '.$batchLabel; $bodyParts = [ '

Please find attached the export for '.htmlspecialchars($batchLabel, ENT_QUOTES, 'UTF-8').'.

', ]; if (trim($message) !== '') { $bodyParts[] = '

Message:
'.nl2br(htmlspecialchars($message, ENT_QUOTES, 'UTF-8')).'

'; } $bodyParts[] = '

Attachments: '.htmlspecialchars(implode(', ', $attachmentSummary), ENT_QUOTES, 'UTF-8').'.

'; $bodyParts[] = '

Regards,
Al Rahma Sunday School

'; $htmlBody = implode("\n", $bodyParts); $attachments = [ ['path' => $csvPath, 'name' => $csvAttachmentName], ]; if ($receiptsArchivePath) { $attachments[] = ['path' => $receiptsArchivePath, 'name' => $batchFileLabel.'_receipts.zip']; } if ($checksArchivePath) { $attachments[] = ['path' => $checksArchivePath, 'name' => $batchFileLabel.'_checks.zip']; } $sender = $this->resolveSender('general'); try { Mail::html($htmlBody, function ($message) use ($recipientEmail, $subject, $sender, $attachments) { $message->to($recipientEmail)->subject($subject); if (! empty($sender['email'])) { $message->from($sender['email'], $sender['name']); } foreach ($attachments as $file) { if (! empty($file['path']) && is_file($file['path'])) { $message->attach($file['path'], ['as' => $file['name'] ?? basename($file['path'])]); } } }); return true; } catch (\Throwable $e) { Log::error('Failed to send batch export email: '.$e->getMessage()); return false; } finally { foreach ($cleanupPaths as $path) { if ($path && is_file($path)) { @unlink($path); } } } } private function fetchBatchReceiptRows(int $batchId, array $receiptIds = []): array { $builder = DB::table('reimbursement_batch_items as bi') ->select('e.id as expense_id', 'e.retailor', 'e.amount', 'e.description', 'e.receipt_path as receipt_filename', 'e.date_of_purchase as purchase_date', 'u.firstname as purchaser_firstname', 'u.lastname as purchaser_lastname') ->join('expenses as e', 'e.id', '=', 'bi.expense_id') ->leftJoin('users as u', 'u.id', '=', 'e.purchased_by') ->where('bi.batch_id', $batchId); if (! empty($receiptIds)) { $builder->whereIn('e.id', $receiptIds); } $rows = $builder->get()->toArray(); return array_values(array_filter(array_map(static function ($row) { return [ 'expense_id' => $row->expense_id ?? null, 'retailor' => $row->retailor ?? null, 'amount' => $row->amount ?? null, 'description' => $row->description ?? null, 'receipt_filename' => $row->receipt_filename ?? null, 'purchase_date' => $row->purchase_date ?? null, 'purchaser_firstname' => $row->purchaser_firstname ?? null, 'purchaser_lastname' => $row->purchaser_lastname ?? null, ]; }, $rows), static function ($row) { return ! empty($row['receipt_filename']); })); } private function writeBatchCsvMetadata($handle, string $batchLabel, array $batch): void { $closedAtRaw = $batch['closed_at'] ?? null; fputcsv($handle, ['Batch Title', $batchLabel]); fputcsv($handle, ['School Year', $batch['school_year'] ?? '', 'Semester', $batch['semester'] ?? '', 'Closed At', $this->formatCsvDate($closedAtRaw)]); fputcsv($handle, []); fputcsv($handle, ['Amount', 'Credit', 'Paid', 'Balance', 'Date', 'By Who', 'Vendor', 'Description']); } private function writeBatchCsvSummary($handle, array $receiptRows, string $totalsDate = ''): void { $receiptTotalsByRecipient = []; foreach ($receiptRows as $row) { $recipientName = trim(($row['purchaser_firstname'] ?? '').' '.($row['purchaser_lastname'] ?? '')); if ($recipientName === '') { $recipientName = 'Unknown'; } $vendor = trim((string) ($row['retailor'] ?? '')); if ($vendor === '') { $vendor = 'Vendor N/A'; } $dateText = $this->formatCsvDate($row['purchase_date'] ?? null); $descriptionValue = trim((string) ($row['description'] ?? '')); $descriptionParts = $descriptionValue !== '' ? [$descriptionValue] : ['Receipt']; $amountValue = (float) ($row['amount'] ?? 0); $receiptTotalsByRecipient[$recipientName] = ($receiptTotalsByRecipient[$recipientName] ?? 0.0) + $amountValue; fputcsv($handle, [ number_format($amountValue, 2, '.', ''), '', number_format($amountValue, 2, '.', ''), '0.00', $dateText, $recipientName, $vendor, implode(' | ', $descriptionParts), ]); } if (! empty($receiptTotalsByRecipient)) { fputcsv($handle, []); fputcsv($handle, ['Totals by Recipient', '', '', '', '', '', '', '']); foreach ($receiptTotalsByRecipient as $recipient => $total) { $formatted = number_format($total, 2, '.', ''); fputcsv($handle, [ $formatted, '', $formatted, '0.00', $totalsDate, $recipient, '', '', ]); } } } private function sanitizeAttachmentComponent(string $value): string { $clean = preg_replace('/[^A-Za-z0-9]+/', '_', trim((string) $value)); $clean = trim($clean, '_'); return $clean === '' ? 'unknown' : $clean; } private function formatAttachmentDate(?string $value): string { if (! $value) { return 'unknown'; } $timestamp = strtotime($value); if ($timestamp === false) { return 'unknown'; } return date('Ymd', $timestamp); } private function uniqueZipEntryName(string $base, string $extension, array &$used): string { $candidate = $base.$extension; $counter = 1; while (isset($used[$candidate])) { $candidate = $base.'_'.$counter.$extension; $counter++; } $used[$candidate] = true; return $candidate; } private function formatCsvDate(?string $value): string { if (! $value) { return ''; } $timestamp = strtotime($value); if ($timestamp === false) { return ''; } return date('m-d-Y', $timestamp); } private function resolveSender(string $fromKey): array { $fromKey = $fromKey !== '' ? $fromKey : 'general'; $json = env('MAIL_SENDERS', '{}'); $senders = json_decode($json, true); if (is_array($senders) && isset($senders[$fromKey])) { $info = $senders[$fromKey]; return [ 'name' => (string) ($info['name'] ?? 'Sender'), 'email' => (string) ($info['email'] ?? ''), ]; } $fallbackAddress = (string) (config('mail.from.address') ?: (getenv('SMTP_USER') ?: '')); $fallbackName = (string) (config('mail.from.name') ?: 'Al Rahma Sunday School'); return [ 'name' => $fallbackName, 'email' => $fallbackAddress, ]; } }