add controllers, servoices
This commit is contained in:
@@ -0,0 +1,358 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services\Reimbursements;
|
||||
|
||||
use App\Models\ReimbursementBatch;
|
||||
use App\Models\ReimbursementBatchAdminFile;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
use Illuminate\Support\Facades\Mail;
|
||||
use ZipArchive;
|
||||
|
||||
class ReimbursementEmailService
|
||||
{
|
||||
public function __construct(private ReimbursementRecipientService $recipients)
|
||||
{
|
||||
}
|
||||
|
||||
public function sendBatchEmail(
|
||||
int $batchId,
|
||||
string $recipientEmail,
|
||||
string $message,
|
||||
array $receiptIds,
|
||||
array $checkIds
|
||||
): bool {
|
||||
$batch = ReimbursementBatch::query()->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 = [
|
||||
'<p>Please find attached the export for <strong>' . htmlspecialchars($batchLabel, ENT_QUOTES, 'UTF-8') . '</strong>.</p>',
|
||||
];
|
||||
if (trim($message) !== '') {
|
||||
$bodyParts[] = '<p><strong>Message:</strong><br>' . nl2br(htmlspecialchars($message, ENT_QUOTES, 'UTF-8')) . '</p>';
|
||||
}
|
||||
$bodyParts[] = '<p>Attachments: ' . htmlspecialchars(implode(', ', $attachmentSummary), ENT_QUOTES, 'UTF-8') . '.</p>';
|
||||
$bodyParts[] = '<p>Regards,<br>Al Rahma Sunday School</p>';
|
||||
$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,
|
||||
];
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user