add controllers, servoices

This commit is contained in:
root
2026-03-09 02:52:13 -04:00
parent c8de5f7edc
commit d76c871cb7
501 changed files with 34439 additions and 21843 deletions
@@ -0,0 +1,121 @@
<?php
namespace App\Services\Reimbursements;
use App\Models\ReimbursementBatch;
use App\Models\ReimbursementBatchItem;
use Illuminate\Support\Facades\Log;
use RuntimeException;
class ReimbursementBatchAssignmentService
{
public function __construct(private ReimbursementLookupService $lookupService)
{
}
public function updateAssignment(
int $expenseId,
int $batchId,
?int $adminId,
?int $reimbursementId,
string $schoolYear,
string $semester
): array {
if ($expenseId <= 0) {
throw new RuntimeException('Invalid expense id.');
}
$now = now();
$activeItem = ReimbursementBatchItem::query()
->where('expense_id', $expenseId)
->whereNull('unassigned_at')
->first();
if ($batchId <= 0) {
if ($activeItem) {
$activeItem->update(['unassigned_at' => $now]);
if (!$reimbursementId && !empty($activeItem->reimbursement_id)) {
$reimbursementId = (int) $activeItem->reimbursement_id;
}
}
return [
'batch_id' => 0,
'admin_id' => null,
'reimbursement_id' => $reimbursementId,
];
}
$batch = ReimbursementBatch::query()->find($batchId);
if (!$batch || strtolower((string) $batch->status) !== ReimbursementBatch::STATUS_OPEN) {
throw new RuntimeException('Batch not found or already closed.');
}
$currentAdmin = $activeItem ? (int) ($activeItem->admin_id ?? 0) : null;
$activeSameBatch = $activeItem && (int) ($activeItem->batch_id ?? 0) === $batchId;
if ($activeSameBatch && ($currentAdmin === ($adminId ?? 0))) {
return [
'batch_id' => $batchId,
'admin_id' => $adminId,
'reimbursement_id' => $activeItem->reimbursement_id ?? null,
];
}
if ($activeSameBatch) {
if (!$reimbursementId) {
$reimbursementId = $activeItem->reimbursement_id
? (int) $activeItem->reimbursement_id
: $this->lookupService->findReimbursementIdForExpense($expenseId);
}
$activeItem->update([
'admin_id' => $adminId,
'assigned_at' => $now,
'reimbursement_id' => $reimbursementId,
'unassigned_at' => $adminId === null ? $now : null,
]);
return [
'batch_id' => $batchId,
'admin_id' => $adminId,
'reimbursement_id' => $reimbursementId,
];
}
if ($activeItem) {
$activeItem->update(['unassigned_at' => $now]);
if (!$reimbursementId && !empty($activeItem->reimbursement_id)) {
$reimbursementId = (int) $activeItem->reimbursement_id;
}
}
if (!$reimbursementId) {
$reimbursementId = $this->lookupService->findReimbursementIdForExpense($expenseId);
}
try {
ReimbursementBatchItem::query()->create([
'batch_id' => $batchId,
'expense_id' => $expenseId,
'reimbursement_id' => $reimbursementId,
'admin_id' => $adminId,
'assigned_at' => $now,
'school_year' => $schoolYear,
'semester' => $semester,
]);
} catch (\Throwable $e) {
Log::error('Failed to assign expense to batch: ' . $e->getMessage(), [
'expense_id' => $expenseId,
'batch_id' => $batchId,
]);
throw new RuntimeException('Unable to update batch assignment right now.');
}
return [
'batch_id' => $batchId,
'admin_id' => $adminId,
'reimbursement_id' => $reimbursementId,
];
}
}
@@ -0,0 +1,154 @@
<?php
namespace App\Services\Reimbursements;
use App\Models\Expense;
use App\Models\Reimbursement;
use App\Models\ReimbursementBatch;
use App\Models\ReimbursementBatchAdminFile;
use App\Models\ReimbursementBatchItem;
use Illuminate\Support\Facades\DB;
use RuntimeException;
class ReimbursementBatchService
{
public function __construct(private ReimbursementLookupService $lookupService)
{
}
public function createBatch(?string $title, int $userId, string $schoolYear, string $semester): array
{
$sequence = $this->nextYearlyBatchNumber($schoolYear);
$now = now();
$batch = ReimbursementBatch::query()->create([
'title' => $title !== null && trim($title) !== '' ? trim($title) : null,
'status' => ReimbursementBatch::STATUS_OPEN,
'created_by' => $userId ?: null,
'opened_at' => $now,
'school_year' => $schoolYear,
'semester' => $semester,
'yearly_batch_number' => $sequence,
]);
$label = trim((string) $batch->title) !== '' ? (string) $batch->title : ('Batch #' . $sequence);
if (trim((string) $batch->title) === '') {
$batch->title = $label;
$batch->save();
}
return [
'batch_id' => (int) $batch->id,
'label' => $label,
'sequence' => $sequence,
];
}
public function lockBatch(int $batchId, int $userId, string $schoolYear, string $semester): void
{
$batch = ReimbursementBatch::query()->find($batchId);
if (!$batch || strtolower((string) $batch->status) !== ReimbursementBatch::STATUS_OPEN) {
throw new RuntimeException('Batch not found or already closed.');
}
$adminRows = ReimbursementBatchItem::query()
->selectRaw('DISTINCT COALESCE(admin_id, 0) AS admin_id')
->where('batch_id', $batchId)
->whereNull('unassigned_at')
->get();
$adminIds = array_values(array_unique($adminRows->pluck('admin_id')->map(fn ($id) => (int) $id)->all()));
$requiredAdmins = array_values(array_filter($adminIds, static fn ($adminId) => $adminId > 0));
if (!empty($requiredAdmins)) {
$uploadedAdminIds = ReimbursementBatchAdminFile::query()
->where('batch_id', $batchId)
->whereIn('admin_id', $requiredAdmins)
->pluck('admin_id')
->map(fn ($id) => (int) $id)
->unique()
->values()
->all();
foreach ($requiredAdmins as $adminId) {
if (!in_array($adminId, $uploadedAdminIds, true)) {
throw new RuntimeException('All admin sections must have a check file before submitting the batch.');
}
}
}
DB::transaction(function () use ($batchId, $userId, $schoolYear, $semester) {
$items = DB::table('reimbursement_batch_items as bi')
->selectRaw('bi.id as batch_item_id, bi.reimbursement_id as batch_reimb_id, bi.expense_id, e.amount, e.purchased_by, e.description, e.reimbursement_id as expense_reimb_id, e.school_year as expense_school_year, e.semester as expense_semester')
->join('expenses as e', 'e.id', '=', 'bi.expense_id')
->where('bi.batch_id', $batchId)
->whereNull('bi.unassigned_at')
->get();
foreach ($items as $item) {
$expenseId = (int) ($item->expense_id ?? 0);
$recipientId = (int) ($item->purchased_by ?? 0);
if ($expenseId <= 0 || $recipientId <= 0) {
continue;
}
$reimbId = $item->batch_reimb_id ?: ($item->expense_reimb_id ?: $this->lookupService->findReimbursementIdForExpense($expenseId));
if (!$reimbId) {
$payload = [
'expense_id' => $expenseId,
'amount' => (float) ($item->amount ?? 0),
'reimbursed_to' => $recipientId,
'approved_by' => $userId ?: null,
'description' => trim((string) ($item->description ?? '')),
'status' => 'Paid',
'added_by' => $userId ?: null,
'school_year' => $item->expense_school_year ?: $schoolYear,
'semester' => $item->expense_semester ?: $semester,
'reimbursement_method' => 'Check',
'batch_number' => $batchId,
'created_at' => now(),
'updated_at' => now(),
];
$reimbId = (int) Reimbursement::query()->create($payload)->id;
}
if ($reimbId) {
Reimbursement::query()->where('id', $reimbId)->update([
'batch_number' => $batchId,
'approved_by' => $userId ?: null,
'status' => 'Paid',
]);
Expense::query()->where('id', $expenseId)->update(['reimbursement_id' => $reimbId]);
if (!empty($item->batch_item_id)) {
ReimbursementBatchItem::query()->where('id', (int) $item->batch_item_id)->update([
'reimbursement_id' => $reimbId,
]);
}
}
}
ReimbursementBatch::query()->where('id', $batchId)->update([
'status' => ReimbursementBatch::STATUS_CLOSED,
'closed_at' => now(),
'closed_by' => $userId ?: null,
]);
});
}
private function nextYearlyBatchNumber(string $schoolYear): int
{
$year = trim($schoolYear);
if ($year === '') {
return 1;
}
$max = ReimbursementBatch::query()
->where('school_year', $year)
->max('yearly_batch_number');
return ((int) $max) + 1;
}
}
@@ -0,0 +1,18 @@
<?php
namespace App\Services\Reimbursements;
use App\Models\Configuration;
class ReimbursementContextService
{
public function getSchoolYear(): string
{
return (string) (Configuration::getConfig('school_year') ?? date('Y'));
}
public function getSemester(): string
{
return (string) (Configuration::getConfig('semester') ?? 'Fall');
}
}
@@ -0,0 +1,97 @@
<?php
namespace App\Services\Reimbursements;
use App\Models\Expense;
use App\Models\Reimbursement;
use RuntimeException;
class ReimbursementCrudService
{
public function store(array $payload, int $userId, string $schoolYear, string $semester, ?string $receiptName): Reimbursement
{
$expenseId = (int) ($payload['expense_id'] ?? 0);
if ($expenseId > 0) {
$expense = Expense::query()->find($expenseId);
if ($expense && strcasecmp((string) $expense->category, 'Donation') === 0) {
throw new RuntimeException('Donation expenses are tracked but should not be reimbursed.');
}
}
$methodRaw = (string) ($payload['reimbursement_method'] ?? '');
$method = ucfirst(strtolower($methodRaw));
$reimb = Reimbursement::query()->create([
'expense_id' => $expenseId ?: null,
'amount' => $payload['amount'],
'reimbursed_to' => (int) $payload['reimbursed_to'],
'description' => $payload['description'] ?? null,
'reimbursement_method' => $method,
'check_number' => $method === 'Check' ? ($payload['check_number'] ?? null) : null,
'receipt_path' => $receiptName,
'school_year' => $payload['school_year'] ?? $schoolYear,
'semester' => $payload['semester'] ?? $semester,
'added_by' => $userId ?: null,
'approved_by' => $userId ?: null,
'status' => 'Paid',
]);
if ($expenseId > 0) {
Expense::query()->where('id', $expenseId)->update(['reimbursement_id' => $reimb->id]);
}
return $reimb;
}
public function process(array $payload, int $userId, string $schoolYear, string $semester, ?string $receiptName): Reimbursement
{
$expenseId = (int) ($payload['expense_id'] ?? 0);
if ($expenseId > 0) {
$expense = Expense::query()->find($expenseId);
if ($expense && strcasecmp((string) $expense->category, 'Donation') === 0) {
throw new RuntimeException('Donation expenses are tracked but should not be reimbursed.');
}
}
$reimb = Reimbursement::query()->create([
'expense_id' => $expenseId ?: null,
'amount' => $payload['amount'],
'reimbursed_to' => (int) $payload['reimbursed_to'],
'approved_by' => $userId ?: null,
'receipt_path' => $receiptName,
'description' => $payload['description'] ?? 'Expense reimbursement',
'status' => 'Paid',
'added_by' => $userId ?: null,
'school_year' => $payload['school_year'] ?? $schoolYear,
'semester' => $payload['semester'] ?? $semester,
'check_number' => $payload['check_number'] ?? null,
'reimbursement_method' => $payload['reimbursement_method'] ?? null,
]);
if ($expenseId > 0) {
Expense::query()->where('id', $expenseId)->update(['reimbursement_id' => $reimb->id]);
}
return $reimb;
}
public function update(Reimbursement $reimb, array $payload, int $userId, ?string $receiptName): Reimbursement
{
$methodRaw = (string) ($payload['reimbursement_method'] ?? $reimb->reimbursement_method);
$method = ucfirst(strtolower($methodRaw));
$updateData = [
'amount' => $payload['amount'],
'reimbursed_to' => (int) $payload['reimbursed_to'],
'description' => $payload['description'] ?? null,
'reimbursement_method' => $method,
'check_number' => $method === 'Check' ? ($payload['check_number'] ?? null) : null,
'receipt_path' => $receiptName,
'updated_by' => $userId ?: null,
];
$reimb->update($updateData);
return $reimb->refresh();
}
}
@@ -0,0 +1,53 @@
<?php
namespace App\Services\Reimbursements;
use App\Models\Expense;
use App\Models\ReimbursementBatchItem;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Log;
use RuntimeException;
class ReimbursementDonationService
{
public function markDonation(int $expenseId, int $userId): void
{
if ($expenseId <= 0) {
throw new RuntimeException('Invalid expense id.');
}
$expense = Expense::query()->find($expenseId);
if (!$expense) {
throw new RuntimeException('Expense not found.');
}
if (!empty($expense->reimbursement_id)) {
throw new RuntimeException('Expense is already tied to a reimbursement.');
}
$now = now();
DB::beginTransaction();
try {
ReimbursementBatchItem::query()
->where('expense_id', $expenseId)
->whereNull('unassigned_at')
->update(['unassigned_at' => $now]);
$expense->update([
'category' => 'Donation',
'status' => 'approved',
'status_reason' => 'Marked as Donation (non-reimbursable).',
'approved_by' => $userId ?: null,
'updated_by' => $userId ?: null,
'reimbursement_id' => null,
]);
DB::commit();
} catch (\Throwable $e) {
DB::rollBack();
Log::error('Failed to mark donation: ' . $e->getMessage(), ['expense_id' => $expenseId]);
throw new RuntimeException('Unable to mark donation right now.');
}
}
}
@@ -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,
];
}
}
@@ -0,0 +1,233 @@
<?php
namespace App\Services\Reimbursements;
use App\Models\ReimbursementBatch;
use Illuminate\Support\Facades\DB;
use RuntimeException;
class ReimbursementExportService
{
public function __construct(private ReimbursementRecipientService $recipients)
{
}
public function buildUnderProcessingCsv(): array
{
$builder = DB::table('expenses')
->select('expenses.*', 'u.firstname as purchaser_firstname', 'u.lastname as purchaser_lastname')
->leftJoin('users as u', 'u.id', '=', 'expenses.purchased_by')
->where('expenses.status', 'approved')
->whereNull('expenses.reimbursement_id')
->where('expenses.category', '!=', 'Donation')
->orderByDesc('expenses.created_at');
$records = $builder->get();
$grouped = [];
foreach ($records as $row) {
$personId = (int) ($row->purchased_by ?? 0);
$name = trim(($row->purchaser_firstname ?? '') . ' ' . ($row->purchaser_lastname ?? ''));
if ($name === '') {
$name = $row->purchaser_firstname ?? $row->purchaser_lastname ?? 'Unknown';
}
$key = $personId > 0 ? 'id_' . $personId : 'name_' . strtolower($name);
if (!isset($grouped[$key])) {
$grouped[$key] = [
'name' => $name ?: 'Unknown',
'total' => 0.0,
'vendors' => [],
'descriptions' => [],
];
}
$amount = (float) ($row->amount ?? 0);
$grouped[$key]['total'] += $amount;
$vendor = trim((string) ($row->retailor ?? ''));
if ($vendor !== '' && !in_array($vendor, $grouped[$key]['vendors'], true)) {
$grouped[$key]['vendors'][] = $vendor;
}
$desc = trim((string) ($row->description ?? ''));
if ($desc !== '' && !in_array($desc, $grouped[$key]['descriptions'], true)) {
$grouped[$key]['descriptions'][] = $desc;
}
}
$rows = [];
$rows[] = ['Person', 'Total Amount', 'Vendors', 'Descriptions'];
foreach ($grouped as $entry) {
$rows[] = [
$entry['name'],
number_format($entry['total'], 2, '.', ''),
implode('; ', $entry['vendors']),
implode('; ', $entry['descriptions']),
];
}
return [
'filename' => 'under_processing_expenses.csv',
'rows' => $rows,
];
}
public function buildProcessedCsv(array $filters): array
{
$builder = DB::table('reimbursements')
->select('reimbursements.*', 'u.firstname as reimb_firstname', 'u.lastname as reimb_lastname')
->leftJoin('users as u', 'u.id', '=', 'reimbursements.reimbursed_to');
if (!empty($filters['semester'])) {
$builder->where('reimbursements.semester', $filters['semester']);
}
if (!empty($filters['status'])) {
$builder->where('reimbursements.status', $filters['status']);
}
if (!empty($filters['user_id'])) {
$builder->where('reimbursements.reimbursed_to', $filters['user_id']);
}
if (!empty($filters['school_year'])) {
$builder->where('reimbursements.school_year', $filters['school_year']);
}
$records = $builder->orderByDesc('reimbursements.created_at')->get();
$rows = [];
$rows[] = ['Amount', 'Reimbursed To', 'Status', 'Method', 'Check #', 'School Year', 'Semester', 'Date'];
foreach ($records as $row) {
$hasName = trim(($row->reimb_firstname ?? '') . ' ' . ($row->reimb_lastname ?? '')) !== '';
if (!$hasName) {
$label = $this->recipients->specialRecipientLabel($row->reimbursed_to ?? null);
if ($label !== null) {
$row->reimb_firstname = $label;
$row->reimb_lastname = '';
}
}
$rows[] = [
$row->amount,
trim(($row->reimb_firstname ?? '') . ' ' . ($row->reimb_lastname ?? '')),
$row->status,
$row->reimbursement_method ?? '-',
$row->check_number ?? '-',
$row->school_year ?? '-',
$row->semester ?? '-',
$row->created_at,
];
}
return [
'filename' => 'reimbursements_export.csv',
'rows' => $rows,
];
}
public function buildBatchCsv(int $batchId): array
{
$batch = ReimbursementBatch::query()->find($batchId);
if (!$batch) {
throw new RuntimeException('Batch not found.');
}
$batchLabel = trim((string) ($batch->title ?? '')) ?: ('Batch #' . $batchId);
$receiptRows = $this->fetchBatchReceiptRows($batchId);
$rows = [];
$this->writeBatchCsvMetadata($rows, $batchLabel, $batch->toArray());
$this->writeBatchCsvSummary($rows, $receiptRows, $this->formatCsvDate($batch->closed_at ?? null));
return [
'filename' => sprintf('batch_%d_items.csv', $batchId),
'rows' => $rows,
];
}
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($rows, static function ($row) {
return !empty($row->receipt_filename);
}));
}
private function writeBatchCsvMetadata(array &$rows, string $batchLabel, array $batch): void
{
$closedAtRaw = $batch['closed_at'] ?? null;
$rows[] = ['Batch Title', $batchLabel];
$rows[] = ['School Year', $batch['school_year'] ?? '', 'Semester', $batch['semester'] ?? '', 'Closed At', $this->formatCsvDate($closedAtRaw)];
$rows[] = [];
$rows[] = ['Amount', 'Credit', 'Paid', 'Balance', 'Date', 'By Who', 'Vendor', 'Description'];
}
private function writeBatchCsvSummary(array &$rows, 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;
$rows[] = [
number_format($amountValue, 2, '.', ''),
'',
number_format($amountValue, 2, '.', ''),
'0.00',
$dateText,
$recipientName,
$vendor,
implode(' | ', $descriptionParts),
];
}
if (!empty($receiptTotalsByRecipient)) {
$rows[] = [];
$rows[] = ['Totals by Recipient', '', '', '', '', '', '', ''];
foreach ($receiptTotalsByRecipient as $recipient => $total) {
$formatted = number_format($total, 2, '.', '');
$rows[] = [
$formatted,
'',
$formatted,
'0.00',
$totalsDate,
$recipient,
'',
'',
];
}
}
}
private function formatCsvDate(?string $value): string
{
if (!$value) {
return '';
}
$timestamp = strtotime($value);
if ($timestamp === false) {
return '';
}
return date('m-d-Y', $timestamp);
}
}
@@ -0,0 +1,119 @@
<?php
namespace App\Services\Reimbursements;
use App\Models\ReimbursementBatchAdminFile;
use Illuminate\Http\UploadedFile;
use Illuminate\Support\Str;
use RuntimeException;
class ReimbursementFileService
{
public function receiptUrl(?string $filename): ?string
{
if (!$filename) {
return null;
}
$safe = basename(trim($filename));
return $safe !== '' ? url('/api/v1/files/reimbursements/' . $safe) : null;
}
public function expenseReceiptUrl(?string $filename): ?string
{
if (!$filename) {
return null;
}
$safe = basename(trim($filename));
return $safe !== '' ? url('/api/v1/files/receipts/' . $safe) : null;
}
public function adminFileUrl(string $filename, string $mode = 'inline'): string
{
return url('/api/v1/finance/reimbursements/batches/admin-files/' . rawurlencode($filename) . '/' . $mode);
}
public function storeReceipt(?UploadedFile $file): ?string
{
if (!$file || !$file->isValid()) {
return null;
}
if (($file->getSize() ?? 0) <= 0) {
return null;
}
$dir = $this->ensureUploadDir();
$extension = $file->getClientOriginalExtension();
$extension = $extension !== '' ? '.' . strtolower($extension) : '';
$filename = 'reimb_' . date('Ymd_His') . '_' . Str::random(8) . $extension;
if (!$file->move($dir, $filename)) {
throw new RuntimeException('Unable to save uploaded file.');
}
return $filename;
}
public function storeAdminFile(int $batchId, int $adminId, UploadedFile $file, int $userId): array
{
$dir = $this->ensureUploadDir();
$extension = $file->getClientOriginalExtension();
$extension = $extension !== '' ? '.' . strtolower($extension) : '';
$filename = 'batch_check_' . date('Ymd_His') . '_' . Str::random(8) . $extension;
if (!$file->move($dir, $filename)) {
throw new RuntimeException('Unable to save uploaded file.');
}
$existing = ReimbursementBatchAdminFile::query()
->where('batch_id', $batchId)
->where('admin_id', $adminId)
->first();
if ($existing && !empty($existing->filename)) {
$oldPath = $dir . DIRECTORY_SEPARATOR . $existing->filename;
if (is_file($oldPath)) {
@unlink($oldPath);
}
}
$payload = [
'batch_id' => $batchId,
'admin_id' => $adminId,
'filename' => $filename,
'original_filename' => $file->getClientOriginalName(),
'uploaded_at' => now(),
'uploaded_by' => $userId ?: null,
];
if ($existing) {
$existing->fill($payload);
$existing->save();
} else {
ReimbursementBatchAdminFile::query()->create($payload);
}
return [
'filename' => $filename,
'original_filename' => $file->getClientOriginalName(),
'url' => $this->adminFileUrl($filename, 'inline'),
];
}
private function ensureUploadDir(): string
{
$dir = storage_path('uploads/reimbursements');
if (!is_dir($dir)) {
if (!@mkdir($dir, 0755, true) && !is_dir($dir)) {
throw new RuntimeException('Upload directory is not available.');
}
}
if (!is_writable($dir)) {
throw new RuntimeException('Upload directory is not writable.');
}
return $dir;
}
}
@@ -0,0 +1,23 @@
<?php
namespace App\Services\Reimbursements;
use App\Models\Reimbursement;
class ReimbursementLookupService
{
public function findReimbursementIdForExpense(int $expenseId): ?int
{
if ($expenseId <= 0) {
return null;
}
$row = Reimbursement::query()
->select('id')
->where('expense_id', $expenseId)
->orderByDesc('id')
->first();
return $row ? (int) $row->id : null;
}
}
@@ -0,0 +1,498 @@
<?php
namespace App\Services\Reimbursements;
use App\Models\Expense;
use App\Models\ReimbursementBatchAdminFile;
use Illuminate\Support\Facades\DB;
class ReimbursementQueryService
{
public function __construct(
private ReimbursementRecipientService $recipients,
private ReimbursementFileService $files
) {
}
public function underProcessing(): array
{
$pendingQuery = DB::table('expenses as e')
->selectRaw('e.id as expense_id, e.amount as expense_amount, e.description, e.receipt_path as expense_receipt, e.retailor, e.purchased_by, u.firstname as purchaser_firstname, u.lastname as purchaser_lastname')
->leftJoin('users as u', 'u.id', '=', 'e.purchased_by')
->leftJoin('reimbursement_batch_items as bi', function ($join) {
$join->on('bi.expense_id', '=', 'e.id');
$join->whereNull('bi.unassigned_at');
})
->whereNull('e.reimbursement_id')
->where('e.category', '!=', 'Donation')
->where(function ($q) {
$q->where('e.status', 'approved')
->orWhere('e.status', 'Approved')
->orWhere('e.status', 'APPROVED');
})
->whereNull('bi.id')
->orderByDesc('e.created_at');
$pendingRows = $pendingQuery->get();
$pendingItems = [];
$itemsMap = [];
foreach ($pendingRows as $row) {
$expenseId = (int) ($row->expense_id ?? 0);
if ($expenseId <= 0) {
continue;
}
$receiptUrl = $this->files->expenseReceiptUrl($row->expense_receipt ?? null);
$fullName = trim(($row->purchaser_firstname ?? '') . ' ' . ($row->purchaser_lastname ?? '')) ?: 'Unknown';
$item = [
'id' => $expenseId,
'expense_id' => $expenseId,
'reimbursement_id' => null,
'expense_amount' => (float) ($row->expense_amount ?? 0),
'vendor' => (string) ($row->retailor ?? ''),
'description' => trim((string) ($row->description ?? '')),
'purchased_by' => $fullName,
'receipt_url' => $receiptUrl,
'batch_id' => null,
'batch_label' => null,
'admin_id' => null,
'admin_name' => null,
'batch_number' => 0,
];
$pendingItems[] = $item;
$itemsMap[$expenseId] = $item;
}
$assignedQuery = DB::table('reimbursement_batch_items as bi')
->selectRaw('bi.id as batch_item_id, bi.batch_id, bi.admin_id, bi.reimbursement_id, bi.expense_id, b.title as batch_title, b.status as batch_status, b.yearly_batch_number as batch_year_number, e.amount as expense_amount, e.description, e.receipt_path as expense_receipt, e.retailor, purchaser.firstname as purchaser_firstname, purchaser.lastname as purchaser_lastname, admin.firstname as admin_firstname, admin.lastname as admin_lastname')
->join('reimbursement_batches as b', 'b.id', '=', 'bi.batch_id')
->join('expenses as e', 'e.id', '=', 'bi.expense_id')
->leftJoin('users as purchaser', 'purchaser.id', '=', 'e.purchased_by')
->leftJoin('users as admin', 'admin.id', '=', 'bi.admin_id')
->where('b.status', 'open')
->whereNull('bi.unassigned_at')
->whereNull('e.reimbursement_id')
->where('e.category', '!=', 'Donation')
->where(function ($q) {
$q->where('e.status', 'approved')
->orWhere('e.status', 'Approved')
->orWhere('e.status', 'APPROVED');
})
->orderBy('b.opened_at', 'ASC')
->orderBy('bi.assigned_at', 'ASC');
$assignedRows = $assignedQuery->get();
$batchStructures = [];
foreach ($assignedRows as $row) {
$expenseId = (int) ($row->expense_id ?? 0);
$batchId = (int) ($row->batch_id ?? 0);
if ($expenseId <= 0 || $batchId <= 0) {
continue;
}
$receiptUrl = $this->files->expenseReceiptUrl($row->expense_receipt ?? null);
$purchaser = trim(($row->purchaser_firstname ?? '') . ' ' . ($row->purchaser_lastname ?? '')) ?: 'Unknown';
$yearlyNumber = (int) ($row->batch_year_number ?? 0);
$batchLabel = trim((string) ($row->batch_title ?? ''));
if ($batchLabel === '') {
$fallbackNumber = $yearlyNumber > 0 ? $yearlyNumber : $batchId;
$batchLabel = 'Batch #' . $fallbackNumber;
}
$adminId = $row->admin_id ? (int) $row->admin_id : 0;
$adminName = $adminId > 0
? (trim(($row->admin_firstname ?? '') . ' ' . ($row->admin_lastname ?? '')) ?: 'Admin')
: 'Unassigned';
$batchStatus = strtolower(trim((string) ($row->batch_status ?? ''))) ?: 'open';
$item = [
'id' => $expenseId,
'expense_id' => $expenseId,
'reimbursement_id' => $row->reimbursement_id ? (int) $row->reimbursement_id : null,
'expense_amount' => (float) ($row->expense_amount ?? 0),
'vendor' => (string) ($row->retailor ?? ''),
'description' => trim((string) ($row->description ?? '')),
'purchased_by' => $purchaser,
'receipt_url' => $receiptUrl,
'batch_id' => $batchId,
'batch_label' => $batchLabel,
'admin_id' => $adminId ?: null,
'admin_name' => $adminId > 0 ? $adminName : null,
'batch_number' => $batchId,
];
$itemsMap[$expenseId] = $item;
if (!isset($batchStructures[$batchId])) {
$batchStructures[$batchId] = [
'batchId' => $batchId,
'label' => $batchLabel,
'slots' => [],
'status' => $batchStatus,
'yearly_batch_number' => $yearlyNumber,
];
} else {
$batchStructures[$batchId]['status'] = $batchStatus;
if ($yearlyNumber > 0) {
$batchStructures[$batchId]['yearly_batch_number'] = $yearlyNumber;
}
}
$slotKey = $adminId ?: 0;
if (!isset($batchStructures[$batchId]['slots'][$slotKey])) {
$batchStructures[$batchId]['slots'][$slotKey] = [
'admin_id' => $adminId ?: 0,
'admin_name' => $adminId > 0 ? $adminName : 'Unassigned',
'items' => [],
'check_file' => null,
];
}
$batchStructures[$batchId]['slots'][$slotKey]['items'][] = $expenseId;
}
$adminFileMap = [];
if (!empty($batchStructures)) {
$batchIds = array_keys($batchStructures);
$files = ReimbursementBatchAdminFile::query()
->select('batch_id', 'admin_id', 'filename', 'original_filename')
->whereIn('batch_id', $batchIds)
->get();
foreach ($files as $file) {
$batchId = (int) ($file->batch_id ?? 0);
$adminId = (int) ($file->admin_id ?? 0);
if ($batchId <= 0) {
continue;
}
$adminFileMap[$batchId][$adminId] = [
'filename' => $file->filename,
'original_filename' => $file->original_filename ?? null,
'url' => $this->files->adminFileUrl($file->filename ?? '', 'inline'),
];
}
}
foreach ($batchStructures as $batchId => &$batch) {
foreach ($batch['slots'] as $slotKey => &$slot) {
$adminId = $slot['admin_id'] ?? 0;
$slot['check_file'] = $adminFileMap[$batchId][$adminId] ?? null;
}
unset($slot);
}
unset($batch);
$existingBatches = array_map(static function (array $batch) {
$normalizedSlots = array_map(static function (array $slot) {
$slot['items'] = array_values(array_map('intval', $slot['items']));
return $slot;
}, array_values($batch['slots']));
$itemIds = [];
foreach ($normalizedSlots as $slot) {
$itemIds = array_merge($itemIds, $slot['items']);
}
return [
'batchId' => $batch['batchId'],
'batchNumber' => $batch['batchId'],
'label' => $batch['label'],
'sequence' => $batch['yearly_batch_number'] ?? 0,
'yearly_batch_number' => $batch['yearly_batch_number'] ?? 0,
'slots' => $normalizedSlots,
'itemIds' => array_values(array_unique($itemIds)),
'status' => $batch['status'] ?? 'open',
];
}, array_values($batchStructures));
$admins = $this->recipients->adminRoster();
return [
'pendingItems' => $pendingItems,
'existingBatches' => $existingBatches,
'adminUsers' => $admins,
'itemsPayload' => array_values($itemsMap),
];
}
public function index(array $filters): array
{
$expenses = Expense::getReimbursedExpensesWithDetails($filters)
->get()
->map(static fn ($row) => (array) $row)
->all();
$batchSummaries = [];
$batchDetails = [];
$batchAttachments = [];
$batchQuery = DB::table('reimbursement_batches as b')
->selectRaw('b.id as batch_id, b.title as batch_title, b.yearly_batch_number, b.closed_at, b.school_year as batch_school_year, e.id as expense_id, e.amount as expense_amount, e.description, e.retailor, e.receipt_path as expense_receipt, e.date_of_purchase as expense_date_of_purchase, e.purchased_by, u.firstname as purchaser_firstname, u.lastname as purchaser_lastname, r.amount as reimb_amount')
->join('reimbursement_batch_items as bi', 'bi.batch_id', '=', 'b.id')
->join('expenses as e', 'e.id', '=', 'bi.expense_id')
->leftJoin('users as u', 'u.id', '=', 'e.purchased_by')
->leftJoin('reimbursements as r', 'r.expense_id', '=', 'e.id')
->where('b.status', 'closed')
->whereNull('bi.unassigned_at');
if (!empty($filters['school_year'])) {
$batchQuery->where(function ($q) use ($filters) {
$q->where('b.school_year', $filters['school_year'])
->orWhere('e.school_year', $filters['school_year']);
});
}
if (!empty($filters['user_id'])) {
$batchQuery->where('e.purchased_by', $filters['user_id']);
}
$batchRows = $batchQuery->get();
foreach ($batchRows as $row) {
$bid = (int) ($row->batch_id ?? 0);
if ($bid <= 0) {
continue;
}
$labelNumber = (int) ($row->yearly_batch_number ?? $bid);
if (!isset($batchSummaries[$bid])) {
$batchSummaries[$bid] = [
'batch_id' => $bid,
'title' => trim((string) ($row->batch_title ?? '')) ?: ('Batch #' . $labelNumber),
'sequence' => $labelNumber,
'closed_at' => $row->closed_at ?? null,
'amount' => 0.0,
'items' => 0,
];
}
$amount = $row->reimb_amount ?? $row->expense_amount ?? 0;
$batchSummaries[$bid]['amount'] += (float) $amount;
$batchSummaries[$bid]['items'] += 1;
if (!isset($batchDetails[$bid])) {
$batchDetails[$bid] = [
'title' => $batchSummaries[$bid]['title'],
'sequence' => $labelNumber,
'closed_at' => $batchSummaries[$bid]['closed_at'],
'items' => [],
'checks' => [],
];
}
if (!isset($batchAttachments[$bid])) {
$batchAttachments[$bid] = [
'receipts' => [],
'checks' => [],
];
}
$batchDetails[$bid]['items'][] = [
'amount' => (float) $amount,
'description' => $row->description ?? '',
'vendor' => $row->retailor ?? '',
'purchased_by' => trim(($row->purchaser_firstname ?? '') . ' ' . ($row->purchaser_lastname ?? '')),
'receipt_url' => $this->files->expenseReceiptUrl($row->expense_receipt ?? null),
];
$receiptFilename = trim((string) ($row->expense_receipt ?? ''));
if ($receiptFilename !== '') {
$batchAttachments[$bid]['receipts'][] = [
'expense_id' => (int) ($row->expense_id ?? 0),
'receipt_filename' => $receiptFilename,
'receipt_url' => $this->files->expenseReceiptUrl($receiptFilename),
'purchaser_firstname' => $row->purchaser_firstname ?? '',
'purchaser_lastname' => $row->purchaser_lastname ?? '',
'vendor' => $row->retailor ?? '',
'description' => $row->description ?? '',
'amount' => (float) ($row->expense_amount ?? 0),
'date_of_purchase' => $row->expense_date_of_purchase ?? '',
];
}
}
if (!empty($batchSummaries)) {
$batchIds = array_keys($batchSummaries);
$files = ReimbursementBatchAdminFile::query()
->select('id as file_id', 'batch_id', 'admin_id', 'filename', 'original_filename', 'uploaded_at')
->whereIn('batch_id', $batchIds)
->get();
$adminNames = $this->recipients->adminRoster();
$adminNameMap = [];
foreach ($adminNames as $admin) {
$adminNameMap[(int) $admin['id']] = trim(($admin['firstname'] ?? '') . ' ' . ($admin['lastname'] ?? ''));
}
foreach ($files as $file) {
$bid = (int) ($file->batch_id ?? 0);
if ($bid <= 0 || !isset($batchDetails[$bid])) {
continue;
}
$aid = (int) ($file->admin_id ?? 0);
$batchDetails[$bid]['checks'][] = [
'admin' => $adminNameMap[$aid] ?? ($aid > 0 ? 'Admin #' . $aid : 'Unassigned'),
'filename' => $file->filename,
'original' => $file->original_filename ?? $file->filename,
'url' => $this->files->adminFileUrl($file->filename ?? '', 'inline'),
];
if (!isset($batchAttachments[$bid])) {
$batchAttachments[$bid] = [
'receipts' => [],
'checks' => [],
];
}
$batchAttachments[$bid]['checks'][] = [
'file_id' => (int) ($file->file_id ?? 0),
'filename' => $file->filename,
'original_filename' => $file->original_filename ?? $file->filename,
'admin' => $adminNameMap[$aid] ?? ($aid > 0 ? 'Admin #' . $aid : 'Unassigned'),
'url' => $this->files->adminFileUrl($file->filename ?? '', 'inline'),
'uploaded_at' => $file->uploaded_at ?? null,
];
}
}
if (!empty($batchSummaries)) {
$batchSummaries = array_values($batchSummaries);
usort($batchSummaries, static function ($a, $b) {
$aSeq = (int) ($a['sequence'] ?? 0);
$bSeq = (int) ($b['sequence'] ?? 0);
return $bSeq <=> $aSeq;
});
}
$donationBatch = null;
$donationDetails = [
'items' => [],
'checks' => [],
];
$donationQuery = DB::table('expenses as e')
->selectRaw('e.id as expense_id, e.amount as expense_amount, e.description, e.retailor, e.receipt_path as expense_receipt, e.purchased_by, e.school_year, e.semester, e.status, u.firstname as purchaser_firstname, u.lastname as purchaser_lastname')
->leftJoin('users as u', 'u.id', '=', 'e.purchased_by')
->where('e.category', 'Donation');
if (!empty($filters['school_year'])) {
$donationQuery->where('e.school_year', $filters['school_year']);
}
if (!empty($filters['semester'])) {
$donationQuery->where('e.semester', $filters['semester']);
}
if (!empty($filters['user_id'])) {
$donationQuery->where('e.purchased_by', $filters['user_id']);
}
if (!empty($filters['status'])) {
$donationQuery->where(function ($q) use ($filters) {
$q->where('e.status', $filters['status'])
->orWhere('e.status', strtolower((string) $filters['status']))
->orWhere('e.status', strtoupper((string) $filters['status']));
});
}
$donationRows = $donationQuery->orderByDesc('e.created_at')->get();
if ($donationRows->isNotEmpty()) {
$donationTotal = 0.0;
foreach ($donationRows as $row) {
$amount = (float) ($row->expense_amount ?? 0);
$donationTotal += $amount;
$purchaser = trim(($row->purchaser_firstname ?? '') . ' ' . ($row->purchaser_lastname ?? ''));
if ($purchaser === '') {
$purchaser = 'Unknown';
}
$donationDetails['items'][] = [
'amount' => $amount,
'description' => $row->description ?? '',
'vendor' => $row->retailor ?? '',
'purchased_by' => $purchaser,
'receipt_url' => $this->files->expenseReceiptUrl($row->expense_receipt ?? null),
];
}
$donationBatch = [
'title' => 'Donations',
'items' => count($donationDetails['items']),
'amount' => $donationTotal,
];
}
foreach ($expenses as &$e) {
if (array_key_exists('expense_receipt', $e)) {
$e['expense_receipt_url'] = $this->files->expenseReceiptUrl($e['expense_receipt']);
} elseif (array_key_exists('receipt_path', $e)) {
$e['expense_receipt_url'] = $this->files->expenseReceiptUrl($e['receipt_path']);
}
if (array_key_exists('reimb_receipt', $e)) {
$e['reimb_receipt_url'] = $this->files->receiptUrl($e['reimb_receipt']);
} elseif (array_key_exists('receipt_path_reimb', $e)) {
$e['reimb_receipt_url'] = $this->files->receiptUrl($e['receipt_path_reimb']);
}
$recipientId = $e['reimb_recipient_id'] ?? ($e['reimbursed_to'] ?? null);
$hasName = trim(($e['reimb_firstname'] ?? '') . ' ' . ($e['reimb_lastname'] ?? '')) !== '';
if (!$hasName) {
$label = $this->recipients->specialRecipientLabel($recipientId);
if ($label !== null) {
$e['reimb_firstname'] = $label;
$e['reimb_lastname'] = '';
}
}
}
unset($e);
$users = $this->recipients->recipientOptions();
$years = DB::table('reimbursements')
->select('school_year')
->distinct()
->orderByDesc('school_year')
->get();
$schoolYears = array_values(array_filter(array_map(static fn ($row) => $row->school_year ?? null, $years->all())));
return [
'expenses' => $expenses,
'users' => $users,
'schoolYears' => $schoolYears,
'batchSummaries' => $batchSummaries,
'batchDetails' => $batchDetails,
'batchAttachments' => $batchAttachments,
'donationBatch' => $donationBatch,
'donationDetails' => $donationDetails,
];
}
public function reimbursedExpenses(): array
{
$rows = DB::table('reimbursements as r')
->selectRaw('e.amount as expense_amount, e.category, e.description, e.receipt_path as expense_receipt, e.purchased_by, r.amount as reimb_amount, r.reimbursement_method, r.check_number, r.receipt_path as reimb_receipt, r.status as reimb_status, r.created_at, u.firstname as purchaser_firstname, u.lastname as purchaser_lastname, a.firstname as approver_firstname, a.lastname as approver_lastname')
->join('expenses as e', 'e.id', '=', 'r.expense_id')
->leftJoin('users as u', 'u.id', '=', 'e.purchased_by')
->leftJoin('users as a', 'a.id', '=', 'r.approved_by')
->orderByDesc('r.created_at')
->get();
$expenses = [];
foreach ($rows as $row) {
$expenses[] = [
'expense_amount' => (float) ($row->expense_amount ?? 0),
'category' => $row->category ?? null,
'description' => $row->description ?? null,
'expense_receipt' => $row->expense_receipt ?? null,
'purchased_by' => $row->purchased_by ?? null,
'reimb_amount' => (float) ($row->reimb_amount ?? 0),
'reimbursement_method' => $row->reimbursement_method ?? null,
'check_number' => $row->check_number ?? null,
'reimb_receipt' => $row->reimb_receipt ?? null,
'reimb_status' => $row->reimb_status ?? null,
'created_at' => $row->created_at ?? null,
'purchaser_firstname' => $row->purchaser_firstname ?? null,
'purchaser_lastname' => $row->purchaser_lastname ?? null,
'approver_firstname' => $row->approver_firstname ?? null,
'approver_lastname' => $row->approver_lastname ?? null,
'expense_receipt_url' => $this->files->expenseReceiptUrl($row->expense_receipt ?? null),
'reimb_receipt_url' => $this->files->receiptUrl($row->reimb_receipt ?? null),
];
}
return $expenses;
}
}
@@ -0,0 +1,93 @@
<?php
namespace App\Services\Reimbursements;
use Illuminate\Support\Facades\DB;
class ReimbursementRecipientService
{
public const SPECIAL_RECIPIENTS = [
990001 => 'Masjid',
990002 => 'Donation',
];
public function staffUsers(): array
{
$rows = DB::table('users')
->select('users.id', 'users.firstname', 'users.lastname', 'roles.name as role_name')
->leftJoin('user_roles', 'user_roles.user_id', '=', 'users.id')
->leftJoin('roles', 'roles.id', '=', 'user_roles.role_id')
->whereNotNull('roles.name')
->get();
$excludedRoles = array_map('strtolower', ['parent', 'student', 'guest']);
$staff = [];
foreach ($rows as $row) {
$roleName = strtolower((string) ($row->role_name ?? ''));
$id = (int) ($row->id ?? 0);
if ($id <= 0 || in_array($roleName, $excludedRoles, true)) {
continue;
}
if (!isset($staff[$id])) {
$staff[$id] = [
'id' => $id,
'firstname' => $row->firstname ?? '',
'lastname' => $row->lastname ?? '',
];
}
}
uasort($staff, static function (array $a, array $b) {
$nameA = trim(($a['firstname'] ?? '') . ' ' . ($a['lastname'] ?? ''));
$nameB = trim(($b['firstname'] ?? '') . ' ' . ($b['lastname'] ?? ''));
return strcasecmp($nameA, $nameB);
});
return array_values($staff);
}
public function recipientOptions(): array
{
return $this->appendSpecialRecipients($this->staffUsers());
}
public function adminRoster(): array
{
$admins = $this->staffUsers();
if (!empty($admins)) {
return $admins;
}
return $this->recipientOptions();
}
public function specialRecipientLabel($value): ?string
{
if ($value === null || !is_numeric($value)) {
return null;
}
$id = (int) $value;
return self::SPECIAL_RECIPIENTS[$id] ?? null;
}
private function appendSpecialRecipients(array $users): array
{
$existingIds = array_map(static function ($row) {
return isset($row['id']) && is_numeric($row['id']) ? (int) $row['id'] : null;
}, $users);
foreach (self::SPECIAL_RECIPIENTS as $id => $label) {
if (!in_array($id, $existingIds, true)) {
$users[] = [
'id' => $id,
'firstname' => $label,
'lastname' => '',
];
}
}
return $users;
}
}