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 @@ ...@@ -13,4 +13,19 @@
case Discount = 'discount'; case Discount = 'discount';
case WriteOff = 'write_off'; case WriteOff = 'write_off';
case OpeningBalance = 'opening_balance'; 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 @@ ...@@ -10,6 +10,7 @@
use App\Models\User; use App\Models\User;
use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo; use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\HasMany;
use Illuminate\Database\Eloquent\SoftDeletes; use Illuminate\Database\Eloquent\SoftDeletes;
class Expense extends Model class Expense extends Model
...@@ -29,6 +30,11 @@ class Expense extends Model ...@@ -29,6 +30,11 @@ class Expense extends Model
'notes', 'notes',
'attachment_path', 'attachment_path',
'attachment_name', 'attachment_name',
'attachment_disk',
'attachment_mime',
'attachment_size',
'attachment_uploaded_by',
'attachment_uploaded_at',
'status', 'status',
'cancelled_by', 'cancelled_by',
'cancelled_at', 'cancelled_at',
...@@ -44,6 +50,8 @@ protected function casts(): array ...@@ -44,6 +50,8 @@ protected function casts(): array
'payment_method' => PaymentMethod::class, 'payment_method' => PaymentMethod::class,
'expense_date' => 'date', 'expense_date' => 'date',
'cancelled_at' => 'datetime', 'cancelled_at' => 'datetime',
'attachment_size' => 'integer',
'attachment_uploaded_at' => 'datetime',
]; ];
} }
...@@ -61,4 +69,48 @@ public function canceller(): BelongsTo ...@@ -61,4 +69,48 @@ public function canceller(): BelongsTo
{ {
return $this->belongsTo(User::class, 'cancelled_by'); 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 @@ ...@@ -10,6 +10,7 @@
use App\Domain\Shared\Exceptions\DomainException; use App\Domain\Shared\Exceptions\DomainException;
use App\Models\User; use App\Models\User;
use Illuminate\Support\Facades\DB; use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Storage;
class ExpenseService class ExpenseService
{ {
...@@ -66,6 +67,7 @@ public function recordExpense(array $data, User $actor): Expense ...@@ -66,6 +67,7 @@ public function recordExpense(array $data, User $actor): Expense
'expense_date' => $data['expense_date'], 'expense_date' => $data['expense_date'],
'notes' => $data['notes'] ?? null, 'notes' => $data['notes'] ?? null,
'created_by' => $actor->id, 'created_by' => $actor->id,
...$this->attachmentAttributes($data, $actor),
]); ]);
$expenseAccountCode = $this->resolveExpenseAccountCode($data['category']); $expenseAccountCode = $this->resolveExpenseAccountCode($data['category']);
...@@ -102,6 +104,7 @@ public function recordExternalRevenue(array $data, User $actor): Expense ...@@ -102,6 +104,7 @@ public function recordExternalRevenue(array $data, User $actor): Expense
'expense_date' => $data['revenue_date'], 'expense_date' => $data['revenue_date'],
'notes' => $data['notes'] ?? null, 'notes' => $data['notes'] ?? null,
'created_by' => $actor->id, 'created_by' => $actor->id,
...$this->attachmentAttributes($data, $actor),
]); ]);
$revenueAccount = FinancialAccount::where('academy_id', $academyId) $revenueAccount = FinancialAccount::where('academy_id', $academyId)
...@@ -143,6 +146,69 @@ public function recordExternalRevenue(array $data, User $actor): Expense ...@@ -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 public function cancelExpense(Expense $expense, string $reason, User $actor): Expense
{ {
if ($expense->status === 'cancelled') { if ($expense->status === 'cancelled') {
...@@ -228,6 +294,46 @@ private function createExpenseTransaction( ...@@ -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 private function resolveExpenseAccountCode(string $category): string
{ {
return match ($category) { 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 ...@@ -73,12 +73,19 @@ public function save(ExpenseService $service): void
$this->validate(); $this->validate();
try { try {
$attachmentPath = null; // Receipts go to the private disk and are read back through
$attachmentName = null; // ExpenseAttachmentController — the public disk needs a storage
// symlink the containers never create.
$attachment = [];
if ($this->attachment) { if ($this->attachment) {
$attachmentName = $this->attachment->getClientOriginalName(); $attachment = [
$attachmentPath = $this->attachment->store('expenses', 'public'); '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([ $service->recordExpense([
...@@ -91,8 +98,7 @@ public function save(ExpenseService $service): void ...@@ -91,8 +98,7 @@ public function save(ExpenseService $service): void
'receipt_reference' => $this->receipt_reference ?: null, 'receipt_reference' => $this->receipt_reference ?: null,
'expense_date' => $this->expense_date, 'expense_date' => $this->expense_date,
'notes' => $this->notes ?: null, 'notes' => $this->notes ?: null,
'attachment_path' => $attachmentPath, ...$attachment,
'attachment_name' => $attachmentName,
], auth()->user()); ], auth()->user());
session()->flash('success', __('تم تسجيل المصروف بنجاح')); 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="{{ __('إل ...@@ -67,28 +67,26 @@ class="w-full rounded-lg border-gray-300 text-sm py-2" placeholder="{{ __('إل
</thead> </thead>
<tbody class="divide-y divide-gray-100"> <tbody class="divide-y divide-gray-100">
@forelse($expenses as $expense) @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 text-gray-600" dir="ltr">{{ $expense->expense_date?->format('Y-m-d') }}</td>
<td class="px-4 py-3"> <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"> <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() }} {{ $expense->category->label() }}
</span> </span>
</td> </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 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">{{ $expense->recipient_name ?? '—' }}</td>
<td class="px-4 py-3 text-gray-600"> <td class="px-4 py-3 text-gray-600">{{ $expense->payment_method?->label() ?? '—' }}</td>
@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"> <td class="px-4 py-3">
@if($expense->status === 'cancelled') @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> <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="{{ __('إل ...@@ -97,15 +95,20 @@ class="w-full rounded-lg border-gray-300 text-sm py-2" placeholder="{{ __('إل
@endif @endif
</td> </td>
<td class="px-4 py-3 text-gray-500 text-xs">{{ $expense->creator?->name }}</td> <td class="px-4 py-3 text-gray-500 text-xs">{{ $expense->creator?->name }}</td>
<td class="px-4 py-3 text-center"> <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') @if($expense->status !== 'cancelled')
<button wire:click="openCancelModal('{{ $expense->uuid }}')" <button wire:click="openCancelModal('{{ $expense->uuid }}')"
class="text-red-600 hover:text-red-800 text-xs font-medium"> class="text-red-600 hover:text-red-800 text-xs font-medium">
{{ __('إلغاء') }} {{ __('إلغاء') }}
</button> </button>
@else
<span class="text-gray-400 text-xs"></span>
@endif @endif
</div>
</td> </td>
</tr> </tr>
@empty @empty
......
<div>
@php
$isRevenue = $expense->category->value === 'external_revenue';
$cancelled = $expense->isCancelled();
@endphp
{{-- Header --}}
<div class="flex flex-col sm:flex-row sm:items-center justify-between gap-3 mb-4 sm:mb-6">
<div class="flex items-center gap-3">
<a href="{{ route('expenses.list') }}" wire:navigate
class="p-2 rounded-lg border border-gray-200 text-gray-500 hover:bg-gray-50 transition">
<svg class="w-4 h-4 rtl:rotate-180" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 19l-7-7 7-7"/></svg>
</a>
<div>
<h1 class="text-xl sm:text-2xl font-bold text-gray-800">
{{ $isRevenue ? __('تفاصيل الإيراد الخارجي') : __('تفاصيل المصروف') }}
</h1>
<p class="text-xs text-gray-400 font-mono" dir="ltr">{{ $expense->uuid }}</p>
</div>
</div>
@if(!$cancelled)
<button wire:click="$set('showCancelModal', true)"
class="inline-flex items-center gap-2 px-4 py-2 border border-red-200 text-red-700 bg-red-50 rounded-lg hover:bg-red-100 transition text-sm">
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"/></svg>
{{ __('إلغاء المصروف') }}
</button>
@endif
</div>
@if(session('success'))
<div class="mb-4 p-3 bg-green-50 border border-green-200 rounded-lg text-green-700 text-sm">{{ session('success') }}</div>
@endif
@if(session('error'))
<div class="mb-4 p-3 bg-red-50 border border-red-200 rounded-lg text-red-700 text-sm">{{ session('error') }}</div>
@endif
@if($cancelled)
<div class="mb-4 p-4 bg-red-50 border border-red-200 rounded-xl">
<p class="font-semibold text-red-800 text-sm mb-1">{{ __('هذا المصروف ملغى') }}</p>
<p class="text-sm text-red-700">{{ $expense->cancellation_reason }}</p>
<p class="text-xs text-red-500 mt-2">
{{ __('ألغاه') }}: {{ $expense->canceller?->name ?? '—' }}
&middot; <span dir="ltr">{{ $expense->cancelled_at?->format('Y-m-d H:i') }}</span>
</p>
</div>
@endif
<div class="grid grid-cols-1 lg:grid-cols-3 gap-4 lg:gap-6">
{{-- Details --}}
<div class="lg:col-span-2 space-y-4">
<div class="bg-white rounded-xl shadow-sm border border-gray-200 overflow-hidden">
<div class="p-5 border-b border-gray-100 bg-gradient-to-l {{ $isRevenue ? 'from-emerald-50' : 'from-red-50' }} to-white">
<p class="text-sm text-gray-500 mb-1">{{ $isRevenue ? __('قيمة الإيراد') : __('قيمة المصروف') }}</p>
<p class="text-3xl font-bold {{ $isRevenue ? 'text-emerald-700' : 'text-red-700' }}" dir="ltr">
{{ format_money($expense->amount) }}
</p>
</div>
<dl class="divide-y divide-gray-100 text-sm">
<div class="flex px-5 py-3">
<dt class="w-40 shrink-0 text-gray-500">{{ __('الوصف') }}</dt>
<dd class="text-gray-800 font-medium">{{ $expense->description }}</dd>
</div>
<div class="flex px-5 py-3">
<dt class="w-40 shrink-0 text-gray-500">{{ __('الفئة') }}</dt>
<dd>
<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>
</dd>
</div>
<div class="flex px-5 py-3">
<dt class="w-40 shrink-0 text-gray-500">{{ __('التاريخ') }}</dt>
<dd class="text-gray-800" dir="ltr">{{ $expense->expense_date?->format('Y-m-d') }}</dd>
</div>
<div class="flex px-5 py-3">
<dt class="w-40 shrink-0 text-gray-500">{{ $isRevenue ? __('المصدر') : __('المستلم') }}</dt>
<dd class="text-gray-800">{{ $expense->recipient_name ?: '—' }}</dd>
</div>
<div class="flex px-5 py-3">
<dt class="w-40 shrink-0 text-gray-500">{{ __('طريقة الدفع') }}</dt>
<dd class="text-gray-800">{{ $expense->payment_method?->label() ?? '—' }}</dd>
</div>
<div class="flex px-5 py-3">
<dt class="w-40 shrink-0 text-gray-500">{{ __('رقم الإيصال') }}</dt>
<dd class="text-gray-800" dir="ltr">{{ $expense->receipt_reference ?: '—' }}</dd>
</div>
<div class="flex px-5 py-3">
<dt class="w-40 shrink-0 text-gray-500">{{ __('الفرع') }}</dt>
<dd class="text-gray-800">{{ $expense->branch?->name ?? __('غير محدد') }}</dd>
</div>
<div class="flex px-5 py-3">
<dt class="w-40 shrink-0 text-gray-500">{{ __('الحالة') }}</dt>
<dd>
@if($cancelled)
<span class="inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium bg-red-100 text-red-700">{{ __('ملغى') }}</span>
@else
<span class="inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium bg-green-100 text-green-700">{{ __('نشط') }}</span>
@endif
</dd>
</div>
@if($expense->notes)
<div class="flex px-5 py-3">
<dt class="w-40 shrink-0 text-gray-500">{{ __('ملاحظات') }}</dt>
<dd class="text-gray-700 whitespace-pre-line">{{ $expense->notes }}</dd>
</div>
@endif
<div class="flex px-5 py-3">
<dt class="w-40 shrink-0 text-gray-500">{{ __('سجّله') }}</dt>
<dd class="text-gray-800">
{{ $expense->creator?->name ?? '—' }}
<span class="text-gray-400 text-xs ms-2" dir="ltr">{{ $expense->created_at?->format('Y-m-d H:i') }}</span>
</dd>
</div>
</dl>
</div>
{{-- Journal entries: what this expense did to the books --}}
<div class="bg-white rounded-xl shadow-sm border border-gray-200 overflow-hidden">
<div class="px-5 py-3 border-b border-gray-100 flex items-center gap-2">
<svg class="w-4 h-4 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 17v-6h13M9 17H4V7h5m0 10V7m0 0h11"/></svg>
<h2 class="text-sm font-semibold text-gray-700">{{ __('القيود المحاسبية') }}</h2>
</div>
<div class="overflow-x-auto">
<table class="w-full text-sm">
<thead class="bg-gray-50 border-b">
<tr>
<th class="px-4 py-2 text-start text-xs font-medium text-gray-500">{{ __('التاريخ') }}</th>
<th class="px-4 py-2 text-start text-xs font-medium text-gray-500">{{ __('النوع') }}</th>
<th class="px-4 py-2 text-start text-xs font-medium text-gray-500">{{ __('مدين') }}</th>
<th class="px-4 py-2 text-start text-xs font-medium text-gray-500">{{ __('دائن') }}</th>
<th class="px-4 py-2 text-start text-xs font-medium text-gray-500">{{ __('المبلغ') }}</th>
</tr>
</thead>
<tbody class="divide-y divide-gray-100">
@forelse($expense->transactions as $txn)
<tr>
<td class="px-4 py-2 text-gray-600" dir="ltr">{{ $txn->transaction_date?->format('Y-m-d') }}</td>
<td class="px-4 py-2 text-gray-700">{{ $txn->type->label() }}</td>
<td class="px-4 py-2 text-gray-700">
<span class="text-gray-400 text-xs" dir="ltr">{{ $txn->debitAccount?->code }}</span>
{{ $txn->debitAccount?->name_ar ?: $txn->debitAccount?->name }}
</td>
<td class="px-4 py-2 text-gray-700">
<span class="text-gray-400 text-xs" dir="ltr">{{ $txn->creditAccount?->code }}</span>
{{ $txn->creditAccount?->name_ar ?: $txn->creditAccount?->name }}
</td>
<td class="px-4 py-2 font-semibold text-gray-800" dir="ltr">{{ format_money($txn->amount) }}</td>
</tr>
@empty
<tr>
<td colspan="5" class="px-4 py-6 text-center text-gray-400">{{ __('لا توجد قيود مرتبطة') }}</td>
</tr>
@endforelse
</tbody>
</table>
</div>
</div>
</div>
{{-- Attachment --}}
<div class="lg:col-span-1">
<div class="bg-white rounded-xl shadow-sm border border-gray-200 overflow-hidden">
<div class="px-5 py-3 border-b border-gray-100 flex items-center gap-2">
<svg class="w-4 h-4 text-gray-400" 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>
<h2 class="text-sm font-semibold text-gray-700">{{ __('الفاتورة / المرفق') }}</h2>
</div>
<div class="p-5">
@if($expense->hasAttachment())
<div class="rounded-lg border border-gray-200 overflow-hidden bg-gray-50 mb-3">
@if($expense->attachmentIsImage())
<a href="{{ route('expenses.attachment', $expense) }}" target="_blank" rel="noopener">
<img src="{{ route('expenses.attachment', $expense) }}"
alt="{{ $expense->attachment_name }}"
class="w-full max-h-96 object-contain bg-white">
</a>
@elseif($expense->attachmentIsPdf())
<iframe src="{{ route('expenses.attachment', $expense) }}"
title="{{ $expense->attachment_name }}"
class="w-full h-96 bg-white"></iframe>
@else
<div class="p-8 text-center text-gray-400">
<svg class="w-10 h-10 mx-auto mb-2" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M7 21h10a2 2 0 002-2V9.414a1 1 0 00-.293-.707l-5.414-5.414A1 1 0 0012.586 3H7a2 2 0 00-2 2v14a2 2 0 002 2z"/></svg>
<p class="text-xs">{{ __('لا يمكن عرض هذا النوع من الملفات') }}</p>
</div>
@endif
</div>
<p class="text-sm text-gray-700 break-all mb-1">{{ $expense->attachment_name }}</p>
<p class="text-xs text-gray-400 mb-3">
@if($expense->attachment_size)
<span dir="ltr">{{ number_format($expense->attachment_size / 1024, 0) }} KB</span> &middot;
@endif
{{ __('أرفقه') }} {{ $expense->attachmentUploader?->name ?? $expense->creator?->name ?? '—' }}
@if($expense->attachment_uploaded_at)
&middot; <span dir="ltr">{{ $expense->attachment_uploaded_at->format('Y-m-d H:i') }}</span>
@endif
</p>
<div class="flex flex-wrap gap-2">
<a href="{{ route('expenses.attachment', $expense) }}" target="_blank" rel="noopener"
class="inline-flex items-center gap-1.5 px-3 py-1.5 text-xs bg-gray-100 text-gray-700 rounded-lg hover:bg-gray-200 transition">
{{ __('عرض') }}
</a>
<a href="{{ route('expenses.attachment.download', $expense) }}"
class="inline-flex items-center gap-1.5 px-3 py-1.5 text-xs bg-gray-100 text-gray-700 rounded-lg hover:bg-gray-200 transition">
{{ __('تحميل') }}
</a>
@if(!$cancelled)
<button wire:click="$set('showRemoveModal', true)"
class="inline-flex items-center gap-1.5 px-3 py-1.5 text-xs text-red-600 rounded-lg hover:bg-red-50 transition">
{{ __('حذف') }}
</button>
@endif
</div>
@else
<div class="text-center py-6 mb-3">
<svg class="w-10 h-10 mx-auto mb-2 text-gray-300" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M7 16a4 4 0 01-.88-7.903A5 5 0 1115.9 6L16 6a5 5 0 011 9.9M15 13l-3-3m0 0l-3 3m3-3v12"/></svg>
<p class="text-sm text-gray-500">{{ __('لا يوجد مستند مرفق') }}</p>
<p class="text-xs text-gray-400 mt-1">{{ __('ارفع صورة الفاتورة أو الإيصال') }}</p>
</div>
@endif
@if(!$cancelled)
<div class="{{ $expense->hasAttachment() ? 'mt-5 pt-5 border-t border-gray-100' : '' }}">
<label class="block text-sm font-medium text-gray-700 mb-2">
{{ $expense->hasAttachment() ? __('استبدال المرفق') : __('إرفاق مستند') }}
</label>
<input type="file" wire:model="receipt" accept="image/*,application/pdf"
class="block w-full text-sm text-gray-600 file:me-3 file:py-2 file:px-4 file:rounded-lg file:border-0 file:text-sm file:bg-gray-100 file:text-gray-700 hover:file:bg-gray-200">
<div wire:loading wire:target="receipt" class="text-xs text-gray-400 mt-1">{{ __('جارٍ الرفع...') }}</div>
@error('receipt')
<p class="text-xs text-red-600 mt-1">{{ $message }}</p>
@enderror
<button wire:click="uploadReceipt" wire:loading.attr="disabled" wire:target="uploadReceipt,receipt"
class="mt-3 w-full px-4 py-2 text-sm bg-emerald-600 text-white rounded-lg hover:bg-emerald-700 disabled:opacity-50 transition">
<span wire:loading.remove wire:target="uploadReceipt">{{ __('حفظ المرفق') }}</span>
<span wire:loading wire:target="uploadReceipt">{{ __('جارٍ الحفظ...') }}</span>
</button>
</div>
@endif
</div>
</div>
</div>
</div>
{{-- Remove attachment modal --}}
@if($showRemoveModal)
<div class="fixed inset-0 z-50 flex items-center justify-center bg-black/50" wire:click.self="$set('showRemoveModal', false)">
<div class="bg-white rounded-xl shadow-xl w-full max-w-sm mx-4 p-6">
<h3 class="text-lg font-bold text-gray-800 mb-2">{{ __('حذف المرفق') }}</h3>
<p class="text-sm text-gray-600 mb-5">{{ __('سيتم حذف الملف نهائياً. المصروف نفسه لن يتأثر.') }}</p>
<div class="flex gap-3 justify-end">
<button wire:click="$set('showRemoveModal', false)"
class="px-4 py-2 text-sm text-gray-600 hover:text-gray-800 rounded-lg border border-gray-300">{{ __('تراجع') }}</button>
<button wire:click="removeReceipt" wire:loading.attr="disabled"
class="px-4 py-2 text-sm bg-red-600 text-white rounded-lg hover:bg-red-700 disabled:opacity-50">
<span wire:loading.remove wire:target="removeReceipt">{{ __('حذف') }}</span>
<span wire:loading wire:target="removeReceipt">{{ __('جارٍ الحذف...') }}</span>
</button>
</div>
</div>
</div>
@endif
{{-- Cancel modal --}}
@if($showCancelModal)
<div class="fixed inset-0 z-50 flex items-center justify-center bg-black/50" wire:click.self="$set('showCancelModal', false)">
<div class="bg-white rounded-xl shadow-xl w-full max-w-md mx-4 p-6">
<h3 class="text-lg font-bold text-gray-800 mb-4">{{ __('إلغاء المصروف') }}</h3>
<p class="text-sm text-gray-600 mb-4">{{ __('سيتم عكس القيد المالي لهذا المصروف. المصروف سيبقى مرئياً بحالة "ملغى".') }}</p>
<div class="mb-4">
<label class="block text-sm font-medium text-gray-700 mb-1">{{ __('سبب الإلغاء') }} <span class="text-red-500">*</span></label>
<textarea wire:model="cancellationReason" rows="3"
class="w-full rounded-lg border-gray-300 text-sm focus:ring-red-500 focus:border-red-500"
placeholder="{{ __('مثال: تم تسجيل المصروف بالغلط في فرع آخر') }}"></textarea>
@error('cancellationReason')
<p class="text-xs text-red-600 mt-1">{{ $message }}</p>
@enderror
</div>
<div class="flex gap-3 justify-end">
<button wire:click="$set('showCancelModal', false)"
class="px-4 py-2 text-sm text-gray-600 hover:text-gray-800 rounded-lg border border-gray-300">{{ __('تراجع') }}</button>
<button wire:click="confirmCancel" wire:loading.attr="disabled"
class="px-4 py-2 text-sm bg-red-600 text-white rounded-lg hover:bg-red-700 disabled:opacity-50">
<span wire:loading.remove wire:target="confirmCancel">{{ __('تأكيد الإلغاء') }}</span>
<span wire:loading wire:target="confirmCancel">{{ __('جارٍ الإلغاء...') }}</span>
</button>
</div>
</div>
</div>
@endif
</div>
...@@ -249,10 +249,18 @@ ...@@ -249,10 +249,18 @@
->middleware('permission:expenses.create'); ->middleware('permission:expenses.create');
Route::get('/expenses/create', \App\Livewire\Financial\ExpenseForm::class)->name('expenses.create') Route::get('/expenses/create', \App\Livewire\Financial\ExpenseForm::class)->name('expenses.create')
->middleware('permission: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') Route::get('/expenses/rent', \App\Livewire\Financial\FacilityRentList::class)->name('expenses.rent.list')
->middleware('permission:facilities.update'); ->middleware('permission:facilities.update');
Route::get('/expenses/rent/create', \App\Livewire\Financial\FacilityRentForm::class)->name('expenses.rent.create') Route::get('/expenses/rent/create', \App\Livewire\Financial\FacilityRentForm::class)->name('expenses.rent.create')
->middleware('permission:facilities.update'); ->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') Route::get('/revenue/external', \App\Livewire\Financial\ExternalRevenueForm::class)->name('revenue.external.create')
->middleware('permission:expenses.create'); ->middleware('permission:expenses.create');
......
<?php
namespace Tests\Feature;
use App\Domain\Financial\Models\Expense;
use App\Domain\Financial\Services\ExpenseService;
use App\Domain\Shared\Exceptions\DomainException;
use App\Models\User;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
use Illuminate\Support\Facades\Storage;
use Tests\TestCase;
/**
* An expense recorded with a receipt attached used to arrive in the database
* with no receipt at all: ExpenseForm uploaded the file and passed the path to
* ExpenseService::recordExpense(), which built its Expense::create() array by
* hand and simply never copied the two attachment keys across. The file landed
* on disk and the row forgot about it.
*
* Worse, it landed on the `public` disk, which needs a storage symlink the
* containers never create — so even a persisted path would have 404'd.
*/
class ExpenseReceiptTest extends TestCase
{
private User $actor;
protected function setUp(): void
{
parent::setUp();
Storage::fake('local');
$this->app->instance('current_academy', (object) ['id' => 1]);
$this->createMinimalSchema();
$this->actor = new User();
$this->actor->id = 7;
$this->actor->academy_id = 1;
$this->actor->exists = true;
}
private function service(): ExpenseService
{
return new ExpenseService();
}
private function expenseData(array $overrides = []): array
{
return array_merge([
'branch_id' => 1,
'category' => 'supplies',
'amount' => 25000,
'description' => 'أقماع تدريب',
'payment_method' => 'cash',
'expense_date' => '2026-08-30',
], $overrides);
}
// ---- the reported bug -------------------------------------------------
public function test_a_receipt_uploaded_with_a_new_expense_survives_the_write(): void
{
$expense = $this->service()->recordExpense($this->expenseData([
'attachment_path' => 'expenses/receipts/abc.jpg',
'attachment_name' => 'فاتورة.jpg',
'attachment_disk' => 'local',
'attachment_mime' => 'image/jpeg',
'attachment_size' => 4096,
]), $this->actor);
$this->assertSame('expenses/receipts/abc.jpg', $expense->attachment_path);
$this->assertSame('فاتورة.jpg', $expense->attachment_name);
$this->assertSame('local', $expense->attachment_disk);
$this->assertSame('image/jpeg', $expense->attachment_mime);
$this->assertSame(4096, $expense->attachment_size);
$this->assertSame(7, $expense->attachment_uploaded_by);
$this->assertNotNull($expense->attachment_uploaded_at);
}
public function test_an_expense_without_a_receipt_does_not_claim_one_was_uploaded(): void
{
$expense = $this->service()->recordExpense($this->expenseData(), $this->actor);
$this->assertFalse($expense->hasAttachment());
$this->assertNull($expense->attachment_uploaded_by);
$this->assertNull($expense->attachment_uploaded_at);
}
// ---- attaching to an expense already in the ledger --------------------
public function test_a_receipt_can_be_attached_to_an_expense_recorded_without_one(): void
{
$expense = $this->service()->recordExpense($this->expenseData(), $this->actor);
Storage::disk('local')->put('expenses/receipts/scan.pdf', 'pdf-bytes');
$expense = $this->service()->attachReceipt($expense, [
'path' => 'expenses/receipts/scan.pdf',
'name' => 'إيصال المورد.pdf',
'disk' => 'local',
'mime' => 'application/pdf',
'size' => 9,
], $this->actor);
$this->assertTrue($expense->hasAttachment());
$this->assertTrue($expense->attachmentIsPdf());
$this->assertSame(7, $expense->attachment_uploaded_by);
}
public function test_replacing_a_receipt_deletes_the_file_it_displaced(): void
{
Storage::disk('local')->put('expenses/receipts/old.jpg', 'old');
Storage::disk('local')->put('expenses/receipts/new.jpg', 'new');
$expense = $this->service()->recordExpense($this->expenseData([
'attachment_path' => 'expenses/receipts/old.jpg',
'attachment_name' => 'old.jpg',
'attachment_disk' => 'local',
]), $this->actor);
$expense = $this->service()->attachReceipt($expense, [
'path' => 'expenses/receipts/new.jpg',
'name' => 'new.jpg',
'disk' => 'local',
'mime' => 'image/jpeg',
'size' => 3,
], $this->actor);
Storage::disk('local')->assertMissing('expenses/receipts/old.jpg');
Storage::disk('local')->assertExists('expenses/receipts/new.jpg');
$this->assertSame('expenses/receipts/new.jpg', $expense->attachment_path);
}
public function test_removing_a_receipt_clears_the_row_and_the_file(): void
{
Storage::disk('local')->put('expenses/receipts/scan.jpg', 'bytes');
$expense = $this->service()->recordExpense($this->expenseData([
'attachment_path' => 'expenses/receipts/scan.jpg',
'attachment_name' => 'scan.jpg',
'attachment_disk' => 'local',
'attachment_mime' => 'image/jpeg',
]), $this->actor);
$expense = $this->service()->removeReceipt($expense, $this->actor);
Storage::disk('local')->assertMissing('expenses/receipts/scan.jpg');
$this->assertFalse($expense->hasAttachment());
$this->assertNull($expense->attachment_mime);
$this->assertNull($expense->attachment_uploaded_by);
}
public function test_a_cancelled_expense_will_not_take_a_new_receipt(): void
{
$expense = $this->service()->recordExpense($this->expenseData(), $this->actor);
$this->service()->cancelExpense($expense, 'سُجل في الفرع الخطأ', $this->actor);
$this->expectException(DomainException::class);
$this->service()->attachReceipt($expense->fresh(), [
'path' => 'expenses/receipts/late.jpg',
'name' => 'late.jpg',
'disk' => 'local',
'mime' => 'image/jpeg',
'size' => 1,
], $this->actor);
}
public function test_removing_a_receipt_that_was_never_there_is_refused(): void
{
$expense = $this->service()->recordExpense($this->expenseData(), $this->actor);
$this->expectException(DomainException::class);
$this->service()->removeReceipt($expense, $this->actor);
}
// ---- the receipt is evidence for a journal entry ----------------------
public function test_an_expense_exposes_the_journal_entries_it_produced(): void
{
$expense = $this->service()->recordExpense($this->expenseData(), $this->actor);
$this->assertCount(1, $expense->transactions);
$this->service()->cancelExpense($expense, 'مكرر', $this->actor);
$this->assertCount(2, $expense->fresh()->transactions);
}
private function createMinimalSchema(): void
{
Schema::create('expenses', function (Blueprint $table) {
$table->id();
$table->uuid('uuid')->nullable();
$table->unsignedBigInteger('academy_id');
$table->unsignedBigInteger('branch_id')->nullable();
$table->string('category');
$table->bigInteger('amount');
$table->string('description');
$table->string('recipient_name')->nullable();
$table->string('payment_method');
$table->string('receipt_reference')->nullable();
$table->date('expense_date');
$table->text('notes')->nullable();
$table->string('attachment_path')->nullable();
$table->string('attachment_name')->nullable();
$table->string('attachment_disk')->nullable();
$table->string('attachment_mime')->nullable();
$table->unsignedBigInteger('attachment_size')->nullable();
$table->unsignedBigInteger('attachment_uploaded_by')->nullable();
$table->timestamp('attachment_uploaded_at')->nullable();
$table->string('status')->default('active');
$table->unsignedBigInteger('cancelled_by')->nullable();
$table->timestamp('cancelled_at')->nullable();
$table->text('cancellation_reason')->nullable();
$table->unsignedBigInteger('created_by')->nullable();
$table->timestamps();
$table->softDeletes();
});
Schema::create('financial_accounts', function (Blueprint $table) {
$table->id();
$table->unsignedBigInteger('academy_id');
$table->string('code');
$table->string('name');
$table->string('name_ar')->nullable();
$table->string('type')->nullable();
$table->timestamps();
});
Schema::create('transactions', function (Blueprint $table) {
$table->id();
$table->uuid('uuid')->nullable();
$table->unsignedBigInteger('academy_id');
$table->unsignedBigInteger('branch_id')->nullable();
$table->unsignedBigInteger('debit_account_id');
$table->unsignedBigInteger('credit_account_id');
$table->string('reference_type')->nullable();
$table->unsignedBigInteger('reference_id')->nullable();
$table->bigInteger('amount');
$table->string('currency', 8)->default('EGP');
$table->string('type');
$table->string('description')->nullable();
$table->date('transaction_date');
$table->unsignedBigInteger('created_by')->nullable();
$table->timestamp('created_at')->nullable();
});
foreach ([['1000', 'Cash'], ['1010', 'Bank'], ['5020', 'Supplies'], ['5030', 'Maintenance'], ['5050', 'Other']] as [$code, $name]) {
\Illuminate\Support\Facades\DB::table('financial_accounts')->insert([
'academy_id' => 1, 'code' => $code, 'name' => $name, 'name_ar' => $name, 'type' => 'expense',
]);
}
}
}
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