Commit cc417ba9 authored by Claude's avatar Claude

Give every expense a receipt you can open

An expense recorded with a scan attached arrived in the database with no
scan at all. ExpenseForm uploaded the file and passed the path to
ExpenseService::recordExpense(), which builds its Expense::create() array
by hand and never copied the two attachment keys across — so the file
landed on disk and the row forgot about it. It landed on the `public`
disk too, which needs a storage symlink the containers never create, so
even a persisted path would have 404'd.

Receipts now go to the private disk and are read back through
ExpenseAttachmentController, which checks the permission, the academy and
the active branch before streaming a byte.

The list was also a dead end: a row showed a number and a description and
offered nothing but "cancel". Rows are now clickable and carry a view
button, with a paperclip marking the ones that have evidence behind them.

The new detail page is where the expense explains itself — amount,
category, recipient, method, receipt reference, branch, notes, who
recorded it and when, and, if it was cancelled, by whom and why. Below
that sit the journal entries it produced, the original debit/credit pair
and any reversing entry, so the accounting effect is visible rather than
implied. The receipt itself previews inline: images as images, PDFs in a
frame, with download beside them.

An expense recorded without a scan is no longer stuck that way — attach
one from the detail page, replace it (the displaced file is deleted), or
remove it. Every attachment records who uploaded it and when. A cancelled
expense refuses all three: its evidence is frozen with its journal.
Co-Authored-By: 's avatarClaude Opus 5 <noreply@anthropic.com>
parent 2ef6e088
......@@ -13,4 +13,19 @@
case Discount = 'discount';
case WriteOff = 'write_off';
case OpeningBalance = 'opening_balance';
public function label(): string
{
return match ($this) {
self::PaymentReceived => 'تحصيل',
self::PaymentMade => 'صرف',
self::Refund => 'استرداد',
self::Transfer => 'تحويل',
self::Adjustment => 'تسوية',
self::Fee => 'رسوم',
self::Discount => 'خصم',
self::WriteOff => 'إعدام دين',
self::OpeningBalance => 'رصيد افتتاحي',
};
}
}
......@@ -10,6 +10,7 @@
use App\Models\User;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\HasMany;
use Illuminate\Database\Eloquent\SoftDeletes;
class Expense extends Model
......@@ -29,6 +30,11 @@ class Expense extends Model
'notes',
'attachment_path',
'attachment_name',
'attachment_disk',
'attachment_mime',
'attachment_size',
'attachment_uploaded_by',
'attachment_uploaded_at',
'status',
'cancelled_by',
'cancelled_at',
......@@ -44,6 +50,8 @@ protected function casts(): array
'payment_method' => PaymentMethod::class,
'expense_date' => 'date',
'cancelled_at' => 'datetime',
'attachment_size' => 'integer',
'attachment_uploaded_at' => 'datetime',
];
}
......@@ -61,4 +69,48 @@ public function canceller(): BelongsTo
{
return $this->belongsTo(User::class, 'cancelled_by');
}
public function attachmentUploader(): BelongsTo
{
return $this->belongsTo(User::class, 'attachment_uploaded_by');
}
/**
* Every journal entry this expense produced — the original debit/credit
* pair and any reversing entry a cancellation added.
*/
public function transactions(): HasMany
{
return $this->hasMany(Transaction::class, 'reference_id')
->where('reference_type', static::class);
}
public function isCancelled(): bool
{
return $this->status === 'cancelled';
}
public function hasAttachment(): bool
{
return filled($this->attachment_path);
}
/**
* Rows created before the attachment metadata migration landed on the
* `public` disk; everything since goes to the private one.
*/
public function attachmentDiskName(): string
{
return $this->attachment_disk ?: 'public';
}
public function attachmentIsImage(): bool
{
return $this->hasAttachment() && str_starts_with((string) $this->attachment_mime, 'image/');
}
public function attachmentIsPdf(): bool
{
return $this->hasAttachment() && $this->attachment_mime === 'application/pdf';
}
}
......@@ -10,6 +10,7 @@
use App\Domain\Shared\Exceptions\DomainException;
use App\Models\User;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Storage;
class ExpenseService
{
......@@ -66,6 +67,7 @@ public function recordExpense(array $data, User $actor): Expense
'expense_date' => $data['expense_date'],
'notes' => $data['notes'] ?? null,
'created_by' => $actor->id,
...$this->attachmentAttributes($data, $actor),
]);
$expenseAccountCode = $this->resolveExpenseAccountCode($data['category']);
......@@ -102,6 +104,7 @@ public function recordExternalRevenue(array $data, User $actor): Expense
'expense_date' => $data['revenue_date'],
'notes' => $data['notes'] ?? null,
'created_by' => $actor->id,
...$this->attachmentAttributes($data, $actor),
]);
$revenueAccount = FinancialAccount::where('academy_id', $academyId)
......@@ -143,6 +146,69 @@ public function recordExternalRevenue(array $data, User $actor): Expense
});
}
/**
* Attach (or replace) the scan of the paper receipt on an expense that is
* already in the ledger.
*
* The caller has already moved the upload onto a disk — this records where
* it went and who put it there, and cleans up the file it displaced.
*
* @param array{path: string, name: string, disk: string, mime: ?string, size: ?int} $file
*/
public function attachReceipt(Expense $expense, array $file, User $actor): Expense
{
if ($expense->isCancelled()) {
throw new DomainException('لا يمكن إرفاق مستند بمصروف ملغى');
}
$previousPath = $expense->attachment_path;
$previousDisk = $expense->attachment_path ? $expense->attachmentDiskName() : null;
$expense->update([
'attachment_path' => $file['path'],
'attachment_name' => $file['name'],
'attachment_disk' => $file['disk'],
'attachment_mime' => $file['mime'] ?? null,
'attachment_size' => $file['size'] ?? null,
'attachment_uploaded_by' => $actor->id,
'attachment_uploaded_at' => now(),
]);
if ($previousPath && $previousPath !== $file['path']) {
$this->deleteFile($previousDisk, $previousPath);
}
return $expense->refresh();
}
public function removeReceipt(Expense $expense, User $actor): Expense
{
if (!$expense->hasAttachment()) {
throw new DomainException('لا يوجد مستند مرفق بهذا المصروف');
}
if ($expense->isCancelled()) {
throw new DomainException('لا يمكن تعديل مرفقات مصروف ملغى');
}
$path = $expense->attachment_path;
$disk = $expense->attachmentDiskName();
$expense->update([
'attachment_path' => null,
'attachment_name' => null,
'attachment_disk' => null,
'attachment_mime' => null,
'attachment_size' => null,
'attachment_uploaded_by' => null,
'attachment_uploaded_at' => null,
]);
$this->deleteFile($disk, $path);
return $expense->refresh();
}
public function cancelExpense(Expense $expense, string $reason, User $actor): Expense
{
if ($expense->status === 'cancelled') {
......@@ -228,6 +294,46 @@ private function createExpenseTransaction(
]);
}
/**
* Only stamp the uploader when a file actually came with the request —
* otherwise every expense without a receipt would claim one was attached.
*
* @return array<string, mixed>
*/
private function attachmentAttributes(array $data, User $actor): array
{
if (empty($data['attachment_path'])) {
return [];
}
return [
'attachment_path' => $data['attachment_path'],
'attachment_name' => $data['attachment_name'] ?? basename($data['attachment_path']),
'attachment_disk' => $data['attachment_disk'] ?? 'local',
'attachment_mime' => $data['attachment_mime'] ?? null,
'attachment_size' => $data['attachment_size'] ?? null,
'attachment_uploaded_by' => $actor->id,
'attachment_uploaded_at' => now(),
];
}
/**
* A missing file is not an error worth failing the write for — the row is
* already correct and the orphan is harmless.
*/
private function deleteFile(?string $disk, ?string $path): void
{
if (!$disk || !$path) {
return;
}
try {
Storage::disk($disk)->delete($path);
} catch (\Throwable $e) {
report($e);
}
}
private function resolveExpenseAccountCode(string $category): string
{
return match ($category) {
......
<?php
namespace App\Http\Controllers;
use App\Domain\Financial\Models\Expense;
use App\Domain\Shared\Context\BranchContext;
use Illuminate\Support\Facades\Gate;
use Illuminate\Support\Facades\Storage;
use Symfony\Component\HttpFoundation\StreamedResponse;
/**
* Receipts live on the private disk, so they can only be read back through
* here — never by guessing a URL under /storage.
*/
class ExpenseAttachmentController extends Controller
{
public function show(Expense $expense): StreamedResponse
{
return $this->stream($expense, 'inline');
}
public function download(Expense $expense): StreamedResponse
{
return $this->stream($expense, 'attachment');
}
private function stream(Expense $expense, string $disposition): StreamedResponse
{
Gate::authorize('expenses.create');
$this->ensureVisible($expense);
abort_unless($expense->hasAttachment(), 404);
$disk = Storage::disk($expense->attachmentDiskName());
abort_unless($disk->exists($expense->attachment_path), 404);
$filename = $expense->attachment_name ?: basename($expense->attachment_path);
return $disk->download($expense->attachment_path, $filename, [
'Content-Type' => $expense->attachment_mime ?: 'application/octet-stream',
'Content-Disposition' => $disposition . '; filename="' . addslashes($filename) . '"',
]);
}
/**
* The academy scope is already global; the branch is not. Someone looking
* at one branch has no business reading another branch's receipts.
*/
private function ensureVisible(Expense $expense): void
{
// SuperAdmin requests never bind an academy; skip rather than throw a
// BindingResolutionException at them.
if (app()->has('current_academy')) {
abort_unless($expense->academy_id === app('current_academy')->id, 403);
}
$branchId = app(BranchContext::class)->branchId();
abort_if($branchId && $expense->branch_id && $expense->branch_id !== $branchId, 403);
}
}
......@@ -73,12 +73,19 @@ public function save(ExpenseService $service): void
$this->validate();
try {
$attachmentPath = null;
$attachmentName = null;
// Receipts go to the private disk and are read back through
// ExpenseAttachmentController — the public disk needs a storage
// symlink the containers never create.
$attachment = [];
if ($this->attachment) {
$attachmentName = $this->attachment->getClientOriginalName();
$attachmentPath = $this->attachment->store('expenses', 'public');
$attachment = [
'attachment_path' => $this->attachment->store('expenses/receipts', 'local'),
'attachment_name' => $this->attachment->getClientOriginalName(),
'attachment_disk' => 'local',
'attachment_mime' => $this->attachment->getMimeType(),
'attachment_size' => $this->attachment->getSize(),
];
}
$service->recordExpense([
......@@ -91,8 +98,7 @@ public function save(ExpenseService $service): void
'receipt_reference' => $this->receipt_reference ?: null,
'expense_date' => $this->expense_date,
'notes' => $this->notes ?: null,
'attachment_path' => $attachmentPath,
'attachment_name' => $attachmentName,
...$attachment,
], auth()->user());
session()->flash('success', __('تم تسجيل المصروف بنجاح'));
......
<?php
namespace App\Livewire\Financial;
use App\Domain\Financial\Models\Expense;
use App\Domain\Financial\Services\ExpenseService;
use App\Domain\Shared\Exceptions\DomainException;
use App\Domain\Shared\Traits\UsesBranchScope;
use Livewire\Attributes\Layout;
use Livewire\Attributes\Locked;
use Livewire\Attributes\Title;
use Livewire\Component;
use Livewire\WithFileUploads;
#[Layout('layouts.app')]
#[Title('تفاصيل المصروف')]
class ExpenseShow extends Component
{
use UsesBranchScope, WithFileUploads;
#[Locked]
public string $uuid = '';
public $receipt = null;
public bool $showCancelModal = false;
public bool $showRemoveModal = false;
public string $cancellationReason = '';
public function mount(string $uuid): void
{
$this->authorize('expenses.create');
$this->uuid = $uuid;
// Resolve once up front so an out-of-branch or unknown uuid fails on
// the way in rather than on the way out.
$this->expense();
}
/**
* Re-read on every request instead of holding the model in component
* state: the service mutates both the row and its journal, and a stale
* copy would render a ledger that no longer exists.
*/
private function expense(): Expense
{
$expense = Expense::with([
'creator', 'canceller', 'attachmentUploader', 'branch',
'transactions.debitAccount', 'transactions.creditAccount', 'transactions.creator',
])->where('uuid', $this->uuid)->firstOrFail();
$branchId = $this->getActiveBranchId();
abort_if($branchId && $expense->branch_id && $expense->branch_id !== $branchId, 403);
return $expense;
}
public function rules(): array
{
return [
'receipt' => 'required|file|max:5120|mimes:jpg,jpeg,png,pdf,webp',
];
}
public function messages(): array
{
return [
'receipt.required' => 'اختر ملف الإيصال أولاً',
'receipt.max' => 'حجم الملف لا يتجاوز 5 ميجابايت',
'receipt.mimes' => 'الملف يجب أن يكون صورة (jpg, png, webp) أو PDF',
];
}
public function uploadReceipt(ExpenseService $service): void
{
$this->authorize('expenses.create');
$this->validate();
try {
$service->attachReceipt($this->expense(), [
'path' => $this->receipt->store('expenses/receipts', 'local'),
'name' => $this->receipt->getClientOriginalName(),
'disk' => 'local',
'mime' => $this->receipt->getMimeType(),
'size' => $this->receipt->getSize(),
], auth()->user());
$this->receipt = null;
session()->flash('success', __('تم إرفاق المستند بنجاح'));
} catch (DomainException $e) {
session()->flash('error', $e->getMessage());
}
}
public function removeReceipt(ExpenseService $service): void
{
$this->authorize('expenses.create');
try {
$service->removeReceipt($this->expense(), auth()->user());
session()->flash('success', __('تم حذف المرفق'));
} catch (DomainException $e) {
session()->flash('error', $e->getMessage());
}
$this->showRemoveModal = false;
}
public function confirmCancel(ExpenseService $service): void
{
$this->authorize('expenses.create');
$this->validate([
'cancellationReason' => 'required|min:3|max:500',
], [
'cancellationReason.required' => 'يرجى كتابة سبب الإلغاء',
'cancellationReason.min' => 'السبب قصير جداً',
]);
try {
$service->cancelExpense($this->expense(), $this->cancellationReason, auth()->user());
session()->flash('success', __('تم إلغاء المصروف بنجاح'));
} catch (DomainException $e) {
session()->flash('error', $e->getMessage());
}
$this->showCancelModal = false;
$this->cancellationReason = '';
}
public function render()
{
return view('livewire.financial.expense-show', [
'expense' => $this->expense(),
]);
}
}
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* A receipt is only useful if you can prove where it came from. The
* original attachment columns stored a path and a filename and nothing
* else — not the disk it landed on, not the type, not who put it there.
*/
public function up(): void
{
if (!Schema::hasTable('expenses')) {
return;
}
Schema::table('expenses', function (Blueprint $table) {
if (!Schema::hasColumn('expenses', 'attachment_disk')) {
$table->string('attachment_disk', 32)->nullable()->after('attachment_name');
}
if (!Schema::hasColumn('expenses', 'attachment_mime')) {
$table->string('attachment_mime', 128)->nullable()->after('attachment_disk');
}
if (!Schema::hasColumn('expenses', 'attachment_size')) {
$table->unsignedBigInteger('attachment_size')->nullable()->after('attachment_mime');
}
if (!Schema::hasColumn('expenses', 'attachment_uploaded_by')) {
$table->foreignId('attachment_uploaded_by')->nullable()->after('attachment_size')
->constrained('users')->nullOnDelete();
}
if (!Schema::hasColumn('expenses', 'attachment_uploaded_at')) {
$table->timestamp('attachment_uploaded_at')->nullable()->after('attachment_uploaded_by');
}
});
// Rows written before this migration stored their file on the `public`
// disk. Record that, so the streaming controller reads them from the
// disk they actually live on instead of guessing.
if (Schema::hasColumn('expenses', 'attachment_disk')) {
\Illuminate\Support\Facades\DB::table('expenses')
->whereNotNull('attachment_path')
->whereNull('attachment_disk')
->update(['attachment_disk' => 'public']);
}
}
public function down(): void
{
if (!Schema::hasTable('expenses')) {
return;
}
Schema::table('expenses', function (Blueprint $table) {
if (Schema::hasColumn('expenses', 'attachment_uploaded_by')) {
$table->dropConstrainedForeignId('attachment_uploaded_by');
}
foreach (['attachment_disk', 'attachment_mime', 'attachment_size', 'attachment_uploaded_at'] as $column) {
if (Schema::hasColumn('expenses', $column)) {
$table->dropColumn($column);
}
}
});
}
};
......@@ -67,28 +67,26 @@ class="w-full rounded-lg border-gray-300 text-sm py-2" placeholder="{{ __('إل
</thead>
<tbody class="divide-y divide-gray-100">
@forelse($expenses as $expense)
<tr class="hover:bg-gray-50 {{ $expense->status === 'cancelled' ? 'opacity-60' : '' }}">
<tr wire:key="expense-{{ $expense->uuid }}"
x-on:click="Livewire.navigate(@js(route('expenses.show', $expense->uuid)))"
class="hover:bg-gray-50 cursor-pointer {{ $expense->status === 'cancelled' ? 'opacity-60' : '' }}">
<td class="px-4 py-3 text-gray-600" dir="ltr">{{ $expense->expense_date?->format('Y-m-d') }}</td>
<td class="px-4 py-3">
<span class="inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium bg-gray-100 text-gray-700">
{{ $expense->category->label() }}
</span>
</td>
<td class="px-4 py-3 text-gray-800 max-w-xs truncate">{{ $expense->description }}</td>
<td class="px-4 py-3 text-gray-800 max-w-xs">
<div class="flex items-center gap-1.5">
<span class="truncate">{{ $expense->description }}</span>
@if($expense->hasAttachment())
<svg class="w-3.5 h-3.5 shrink-0 text-gray-400" title="{{ __('يوجد مرفق') }}" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15.172 7l-6.586 6.586a2 2 0 102.828 2.828l6.414-6.586a4 4 0 00-5.656-5.656l-6.415 6.585a6 6 0 108.486 8.486L20.5 13"/></svg>
@endif
</div>
</td>
<td class="px-4 py-3 font-semibold text-red-700" dir="ltr">{{ format_money($expense->amount) }}</td>
<td class="px-4 py-3 text-gray-600">{{ $expense->recipient_name ?? '—' }}</td>
<td class="px-4 py-3 text-gray-600">
@php
$methodLabels = [
'cash' => 'نقدي',
'card' => 'بطاقة',
'bank_transfer' => 'تحويل بنكي',
'cheque' => 'شيك',
'other' => 'أخرى',
];
@endphp
{{ $methodLabels[$expense->payment_method->value ?? $expense->payment_method] ?? $expense->payment_method }}
</td>
<td class="px-4 py-3 text-gray-600">{{ $expense->payment_method?->label() ?? '—' }}</td>
<td class="px-4 py-3">
@if($expense->status === 'cancelled')
<span class="inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium bg-red-100 text-red-700" title="{{ $expense->cancellation_reason }}">{{ __('ملغى') }}</span>
......@@ -97,15 +95,20 @@ class="w-full rounded-lg border-gray-300 text-sm py-2" placeholder="{{ __('إل
@endif
</td>
<td class="px-4 py-3 text-gray-500 text-xs">{{ $expense->creator?->name }}</td>
<td class="px-4 py-3 text-center">
@if($expense->status !== 'cancelled')
<button wire:click="openCancelModal('{{ $expense->uuid }}')"
class="text-red-600 hover:text-red-800 text-xs font-medium">
{{ __('إلغاء') }}
</button>
@else
<span class="text-gray-400 text-xs"></span>
@endif
<td class="px-4 py-3 text-center" x-on:click.stop>
<div class="flex items-center justify-center gap-3">
<a href="{{ route('expenses.show', $expense->uuid) }}" wire:navigate
class="inline-flex items-center gap-1 text-emerald-700 hover:text-emerald-900 text-xs font-medium">
<svg class="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z"/><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M2.458 12C3.732 7.943 7.523 5 12 5c4.478 0 8.268 2.943 9.542 7-1.274 4.057-5.064 7-9.542 7-4.477 0-8.268-2.943-9.542-7z"/></svg>
{{ __('عرض') }}
</a>
@if($expense->status !== 'cancelled')
<button wire:click="openCancelModal('{{ $expense->uuid }}')"
class="text-red-600 hover:text-red-800 text-xs font-medium">
{{ __('إلغاء') }}
</button>
@endif
</div>
</td>
</tr>
@empty
......
This diff is collapsed.
......@@ -249,10 +249,18 @@
->middleware('permission:expenses.create');
Route::get('/expenses/create', \App\Livewire\Financial\ExpenseForm::class)->name('expenses.create')
->middleware('permission:expenses.create');
Route::get('/expenses/{expense}/attachment', [\App\Http\Controllers\ExpenseAttachmentController::class, 'show'])
->name('expenses.attachment')->middleware('permission:expenses.create');
Route::get('/expenses/{expense}/attachment/download', [\App\Http\Controllers\ExpenseAttachmentController::class, 'download'])
->name('expenses.attachment.download')->middleware('permission:expenses.create');
Route::get('/expenses/rent', \App\Livewire\Financial\FacilityRentList::class)->name('expenses.rent.list')
->middleware('permission:facilities.update');
Route::get('/expenses/rent/create', \App\Livewire\Financial\FacilityRentForm::class)->name('expenses.rent.create')
->middleware('permission:facilities.update');
// Registered after the literal /expenses/* routes so `create` and `rent`
// are not swallowed by the {uuid} wildcard.
Route::get('/expenses/{uuid}', \App\Livewire\Financial\ExpenseShow::class)->name('expenses.show')
->middleware('permission:expenses.create');
Route::get('/revenue/external', \App\Livewire\Financial\ExternalRevenueForm::class)->name('revenue.external.create')
->middleware('permission:expenses.create');
......
This diff is collapsed.
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment