Commit b3c232ab authored by Mahmoud Aglan's avatar Mahmoud Aglan

feat(expenses): let one expense carry many receipt images

An expense is one payment, not one piece of paper. A month of fuel, or a kit
order settled across a dozen shops, arrives at the desk as a single expense
with a hundred receipts behind it. The schema had one set of `attachment_*`
columns on `expenses`, which encoded the opposite assumption, and
attachReceipt() deleted the file it displaced — so uploading page two of an
invoice destroyed page one and nothing said so.

Receipts move to their own `expense_attachments` table. The migration is
additive and idempotent (guarded by hasTable, backfill deduped by path, so it
is safe against the migrate-on-every-boot entrypoint), and it copies the
existing single attachments across, defaulting the pre-metadata rows to the
`public` disk exactly as attachmentDiskName() did in PHP. The old columns are
deliberately left in place rather than dropped: a client DB is migrated in
place on a live container and a dropped column is not recoverable.

The desk can now select several files at once and keep adding in batches —
selections accumulate instead of replacing, which is what a hundred-receipt
expense actually needs. Deleting one receipt leaves the others alone, and the
delete re-resolves the id through the expense's own relation so an id off the
wire cannot erase another expense's evidence. Streaming moves to the
attachment's own uuid route, with the branch check read from the parent
expense without global scopes so the guard cannot fail open.
Co-Authored-By: 's avatarClaude Opus 5 (1M context) <noreply@anthropic.com>
parent 131911dc
......@@ -75,6 +75,19 @@ public function attachmentUploader(): BelongsTo
return $this->belongsTo(User::class, 'attachment_uploaded_by');
}
/**
* Every receipt filed against this expense.
*
* This is the only source of truth for attachments. The `attachment_*`
* columns above are the single-file design this replaced; they were
* copied into `expense_attachments` by migration and are no longer
* written, kept only so the change can be backed out.
*/
public function attachments(): HasMany
{
return $this->hasMany(ExpenseAttachment::class)->orderBy('id');
}
/**
* Every journal entry this expense produced — the original debit/credit
* pair and any reversing entry a cancellation added.
......@@ -90,38 +103,18 @@ 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.
* Counts the relation, not the legacy column: the column stopped being
* written when attachments moved to their own table, so reading it here
* would report "no receipt" on every expense filed since.
*/
public function attachmentDiskName(): string
{
return $this->attachment_disk ?: 'public';
}
public function attachmentIsImage(): bool
{
return $this->hasAttachment() && str_starts_with((string) $this->attachment_mime, 'image/');
}
/**
* An image an <img> tag can actually paint. HEIC/HEIF is an image and is
* accepted on upload, but only Safari renders it — everywhere else the
* page showed a broken picture, so those fall back to the file card.
*/
public function attachmentIsViewableImage(): bool
public function hasAttachment(): bool
{
return $this->attachmentIsImage()
&& ! in_array($this->attachment_mime, ['image/heic', 'image/heif', 'image/heic-sequence'], true);
return $this->attachments()->exists();
}
public function attachmentIsPdf(): bool
public function attachmentCount(): int
{
return $this->hasAttachment() && $this->attachment_mime === 'application/pdf';
return $this->attachments()->count();
}
}
<?php
namespace App\Domain\Financial\Models;
use App\Domain\Shared\Traits\BelongsToAcademy;
use App\Domain\Shared\Traits\HasUuid;
use App\Models\User;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
/**
* One receipt image or PDF filed against an expense.
*
* There is no BelongsToBranch here on purpose: an attachment has no branch of
* its own, it inherits the one on its expense, and a second scope over the
* same fact is a second thing that can disagree. Reads go through the expense,
* which is branch-scoped already.
*/
class ExpenseAttachment extends Model
{
use HasUuid, BelongsToAcademy;
protected $fillable = [
'academy_id',
'expense_id',
'path',
'name',
'disk',
'mime',
'size',
'uploaded_by',
'uploaded_at',
];
protected function casts(): array
{
return [
'size' => 'integer',
'uploaded_at' => 'datetime',
];
}
public function expense(): BelongsTo
{
return $this->belongsTo(Expense::class);
}
public function uploader(): BelongsTo
{
return $this->belongsTo(User::class, 'uploaded_by');
}
public function diskName(): string
{
return $this->disk ?: 'local';
}
public function isImage(): bool
{
return str_starts_with((string) $this->mime, 'image/');
}
/**
* An image an <img> tag can actually paint. HEIC/HEIF is an image and is
* accepted on upload — it is what an iPhone hands over by default — but
* only Safari renders it, so everywhere else it falls back to a file card
* rather than a broken picture.
*/
public function isViewableImage(): bool
{
return $this->isImage()
&& ! in_array($this->mime, ['image/heic', 'image/heif', 'image/heic-sequence'], true);
}
public function isPdf(): bool
{
return $this->mime === 'application/pdf';
}
public function humanSize(): string
{
$bytes = (int) $this->size;
if ($bytes <= 0) {
return '—';
}
if ($bytes < 1024) {
return $bytes . ' B';
}
if ($bytes < 1048576) {
return round($bytes / 1024) . ' KB';
}
return round($bytes / 1048576, 1) . ' MB';
}
}
......@@ -4,11 +4,13 @@
use App\Domain\Financial\Enums\TransactionType;
use App\Domain\Financial\Models\Expense;
use App\Domain\Financial\Models\ExpenseAttachment;
use App\Domain\Financial\Models\FacilityRentPayment;
use App\Domain\Financial\Models\FinancialAccount;
use App\Domain\Financial\Models\Transaction;
use App\Domain\Shared\Exceptions\DomainException;
use App\Models\User;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Storage;
......@@ -68,9 +70,10 @@ 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),
]);
$this->storeAttachments($expense, $data['attachments'] ?? [], $actor);
$expenseAccountCode = $this->resolveExpenseAccountCode($data['category']);
$this->createExpenseTransaction(
......@@ -106,9 +109,10 @@ 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),
]);
$this->storeAttachments($expense, $data['attachments'] ?? [], $actor);
$revenueAccount = FinancialAccount::where('academy_id', $academyId)
->where('code', '4060')
->first();
......@@ -155,62 +159,73 @@ 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.
* File one or more receipt scans against an expense 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.
* Adds — never replaces. The single-attachment version deleted whatever
* was there before, which meant a desk uploading the second page of a
* two-page invoice destroyed the first and nothing said so. An expense can
* legitimately carry a hundred receipts, so every upload is kept.
*
* @param array{path: string, name: string, disk: string, mime: ?string, size: ?int} $file
* The caller has already moved the uploads onto a disk; this records where
* they went and who put them there.
*
* @param array<int, array{path: string, name: string, disk: string, mime: ?string, size: ?int}> $files
* @return Collection<int, ExpenseAttachment>
*/
public function attachReceipt(Expense $expense, array $file, User $actor): Expense
public function attachReceipts(Expense $expense, array $files, User $actor): Collection
{
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);
if (empty($files)) {
throw new DomainException('لم يتم اختيار أي ملف');
}
return $expense->refresh();
return DB::transaction(function () use ($expense, $files, $actor) {
$created = collect();
foreach ($files as $file) {
$created->push(ExpenseAttachment::create([
'academy_id' => $expense->academy_id,
'expense_id' => $expense->id,
'path' => $file['path'],
'name' => $file['name'] ?: basename($file['path']),
'disk' => $file['disk'] ?? 'local',
'mime' => $file['mime'] ?? null,
'size' => $file['size'] ?? null,
'uploaded_by' => $actor->id,
'uploaded_at' => now(),
]));
}
return $created;
});
}
public function removeReceipt(Expense $expense, User $actor): Expense
/**
* Remove one receipt, named by its own id.
*
* The attachment is re-resolved through the expense rather than trusted as
* passed: an id off the wire that belongs to another expense — or another
* academy — must not delete a file here.
*/
public function removeReceipt(Expense $expense, int $attachmentId, 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,
]);
$attachment = $expense->attachments()->whereKey($attachmentId)->first();
if (!$attachment) {
throw new DomainException('المرفق غير موجود');
}
$disk = $attachment->diskName();
$path = $attachment->path;
$attachment->delete();
$this->deleteFile($disk, $path);
......@@ -312,26 +327,33 @@ 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.
* File the receipts that came in with a brand-new expense.
*
* Runs inside the caller's transaction, so an expense and its evidence are
* written together or not at all. Silent on an empty list: most expenses
* are entered with no scan at all and that is not an error.
*
* @return array<string, mixed>
* @param array<int, array{path: string, name: string, disk: string, mime: ?string, size: ?int}> $files
*/
private function attachmentAttributes(array $data, User $actor): array
private function storeAttachments(Expense $expense, array $files, User $actor): void
{
if (empty($data['attachment_path'])) {
return [];
}
foreach ($files as $file) {
if (empty($file['path'])) {
continue;
}
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(),
];
ExpenseAttachment::create([
'academy_id' => $expense->academy_id,
'expense_id' => $expense->id,
'path' => $file['path'],
'name' => $file['name'] ?? basename($file['path']),
'disk' => $file['disk'] ?? 'local',
'mime' => $file['mime'] ?? null,
'size' => $file['size'] ?? null,
'uploaded_by' => $actor->id,
'uploaded_at' => now(),
]);
}
}
/**
......
......@@ -2,7 +2,7 @@
namespace App\Http\Controllers;
use App\Domain\Financial\Models\Expense;
use App\Domain\Financial\Models\ExpenseAttachment;
use App\Domain\Shared\Context\BranchContext;
use Illuminate\Support\Facades\Gate;
use Illuminate\Support\Facades\Storage;
......@@ -14,14 +14,14 @@
*/
class ExpenseAttachmentController extends Controller
{
public function show(Expense $expense): StreamedResponse
public function show(ExpenseAttachment $attachment): StreamedResponse
{
return $this->stream($expense, 'inline');
return $this->stream($attachment, 'inline');
}
public function download(Expense $expense): StreamedResponse
public function download(ExpenseAttachment $attachment): StreamedResponse
{
return $this->stream($expense, 'attachment');
return $this->stream($attachment, 'attachment');
}
/**
......@@ -32,36 +32,43 @@ public function download(Expense $expense): StreamedResponse
* makeDisposition() writes the RFC 6266 `filename*` form with an ASCII
* fallback, which is what both cases need.
*/
private function stream(Expense $expense, string $disposition): StreamedResponse
private function stream(ExpenseAttachment $attachment, string $disposition): StreamedResponse
{
Gate::authorize('expenses.create');
$this->ensureVisible($expense);
$this->ensureVisible($attachment);
abort_unless($expense->hasAttachment(), 404);
$disk = Storage::disk($attachment->diskName());
$disk = Storage::disk($expense->attachmentDiskName());
abort_unless($disk->exists($attachment->path), 404);
abort_unless($disk->exists($expense->attachment_path), 404);
$filename = $attachment->name ?: basename($attachment->path);
$filename = $expense->attachment_name ?: basename($expense->attachment_path);
$headers = ['Content-Type' => $expense->attachment_mime ?: 'application/octet-stream'];
$headers = ['Content-Type' => $attachment->mime ?: 'application/octet-stream'];
return $disposition === 'attachment'
? $disk->download($expense->attachment_path, $filename, $headers)
: $disk->response($expense->attachment_path, $filename, $headers, 'inline');
? $disk->download($attachment->path, $filename, $headers)
: $disk->response($attachment->path, $filename, $headers, 'inline');
}
/**
* Both scopes are global now, and the route binding already answers to
* them. This says the same thing a second time, in the controller, because
* someone looking at one branch has no business reading another branch's
* receipts and a file stream is the wrong place to trust a single check.
* The attachment carries an academy but no branch of its own, so the
* branch answer has to come from the expense it belongs to. That lookup
* deliberately drops the branch scope — a scoped read would return null
* for an out-of-branch expense and the `$expense &&` test below would then
* let the file through, which is a guard failing open.
*/
private function ensureVisible(Expense $expense): void
private function ensureVisible(ExpenseAttachment $attachment): void
{
// SuperAdmin requests never bind an academy; skip rather than throw a
// BindingResolutionException at them.
if (app()->has('current_academy')) {
abort_unless($attachment->academy_id === app('current_academy')->id, 403);
}
$expense = $attachment->expense()->withoutGlobalScopes()->first();
abort_unless($expense, 404);
if (app()->has('current_academy')) {
abort_unless($expense->academy_id === app('current_academy')->id, 403);
}
......@@ -70,9 +77,7 @@ private function ensureVisible(Expense $expense): void
// No `&& $expense->branch_id` term: an expense is incurred by exactly
// one branch, so a null there is a data bug, and letting it through for
// every branch is the wrong way for a guard to fail. Expense carries
// BranchScope, so the binding normally 404s before this runs — this is
// the belt behind that brace, and a belt must not fail open.
// every branch is the wrong way for a guard to fail.
abort_if($branchId && (int) $expense->branch_id !== $branchId, 403);
}
}
......@@ -26,7 +26,18 @@ class ExpenseForm extends Component
public string $receipt_reference = '';
public ?string $expense_date = null;
public string $notes = '';
public $attachment = null;
/**
* Files chosen but not yet filed. The input binds to $incoming, and
* updatedIncoming() moves them here — see that method for why the two are
* separate.
*
* @var array<int, \Livewire\Features\SupportFileUploads\TemporaryUploadedFile>
*/
public array $attachments = [];
/** @var mixed the file input's own binding, drained on every selection */
public $incoming = [];
public function mount(): void
{
......@@ -45,9 +56,10 @@ public function rules(): array
'receipt_reference' => 'nullable|string|max:100',
'expense_date' => 'required|date',
'notes' => 'nullable|string',
'attachments' => 'array|max:' . config('uploads.max_files'),
// heic/heif: an iPhone photo is HEIC by default, and refusing it
// after the picker accepted it reads as a broken upload.
'attachment' => 'nullable|file|max:' . config('uploads.max_kb') . '|mimes:jpg,jpeg,png,pdf,webp,heic,heif,gif,bmp',
'attachments.*' => 'file|max:' . config('uploads.max_kb') . '|mimes:jpg,jpeg,png,pdf,webp,heic,heif,gif,bmp',
];
}
......@@ -61,19 +73,57 @@ public function messages(): array
'description.max' => 'الوصف يجب ألا يتجاوز 500 حرف',
'payment_method.required' => 'اختر طريقة الدفع',
'expense_date.required' => 'تاريخ المصروف مطلوب',
'attachment.max' => __('حجم الملف لا يتجاوز :mb ميجابايت', ['mb' => intdiv((int) config('uploads.max_kb'), 1024)]),
'attachment.mimes' => 'الملف يجب أن يكون صورة (jpg, png, webp, heic) أو PDF',
'attachments.max' => __('لا يمكن إرفاق أكثر من :count ملف', ['count' => config('uploads.max_files')]),
'attachments.*.max' => __('حجم الملف لا يتجاوز :mb ميجابايت', ['mb' => intdiv((int) config('uploads.max_kb'), 1024)]),
'attachments.*.mimes' => 'الملف يجب أن يكون صورة (jpg, png, webp, heic) أو PDF',
];
}
public function removeAttachment(): void
/**
* Append, never replace.
*
* A file input bound straight to the list overwrites it on every use, so
* picking ten receipts and then remembering an eleventh silently dropped
* the first ten. The desk here uploads in handfuls — that is the whole
* point of the feature — so selections accumulate and the input is drained
* ready for the next one.
*/
public function updatedIncoming(): void
{
$chosen = is_array($this->incoming) ? $this->incoming : [$this->incoming];
$this->incoming = [];
$room = (int) config('uploads.max_files') - count($this->attachments);
if ($room <= 0) {
$this->addError('attachments', __('لا يمكن إرفاق أكثر من :count ملف', ['count' => config('uploads.max_files')]));
return;
}
foreach (array_slice(array_filter($chosen), 0, $room) as $file) {
$this->attachments[] = $file;
}
if (count($chosen) > $room) {
$this->addError('attachments', __('لا يمكن إرفاق أكثر من :count ملف', ['count' => config('uploads.max_files')]));
}
}
public function removeAttachment(int $index): void
{
unset($this->attachments[$index]);
$this->attachments = array_values($this->attachments);
}
public function clearAttachments(): void
{
$this->attachment = null;
$this->attachments = [];
}
public function save(ExpenseService $service): void
{
if (! $this->temporaryUploadIsUsable('attachment', $this->attachment)) {
if (! $this->temporaryUploadIsUsable('attachments', $this->attachments)) {
return;
}
......@@ -83,22 +133,22 @@ public function save(ExpenseService $service): void
// Receipts go to the private disk and are read back through
// ExpenseAttachmentController — the public disk needs a storage
// symlink the containers never create.
$attachment = [];
$attachments = [];
if ($this->attachment) {
foreach ($this->attachments as $file) {
// Metadata first: store() moves the file out of livewire-tmp,
// so any getter called after it reads a path that is gone and
// throws UnableToRetrieveMetadata. See ExpenseShow.
$name = $this->attachment->getClientOriginalName();
$mime = $this->attachment->getMimeType();
$size = $this->attachment->getSize();
$attachment = [
'attachment_path' => $this->attachment->store('expenses/receipts', 'local'),
'attachment_name' => $name,
'attachment_disk' => 'local',
'attachment_mime' => $mime,
'attachment_size' => $size,
$name = $file->getClientOriginalName();
$mime = $file->getMimeType();
$size = $file->getSize();
$attachments[] = [
'path' => $file->store('expenses/receipts', 'local'),
'name' => $name,
'disk' => 'local',
'mime' => $mime,
'size' => $size,
];
}
......@@ -112,7 +162,7 @@ public function save(ExpenseService $service): void
'receipt_reference' => $this->receipt_reference ?: null,
'expense_date' => $this->expense_date,
'notes' => $this->notes ?: null,
...$attachment,
'attachments' => $attachments,
], auth()->user());
session()->flash('success', __('تم تسجيل المصروف بنجاح'));
......
......@@ -86,7 +86,7 @@ public function render()
{
$branchId = $this->getActiveBranchId();
$expenses = Expense::with(['creator'])
$expenses = Expense::with(['creator'])->withCount('attachments')
->when($branchId, fn ($q) => $q->where('branch_id', $branchId))
->when($this->category_filter, fn ($q) => $q->where('category', $this->category_filter))
->when($this->from_date, fn ($q) => $q->where('expense_date', '>=', $this->from_date))
......
......@@ -22,9 +22,14 @@ class ExpenseShow extends Component
#[Locked]
public string $uuid = '';
public $receipt = null;
/** @var mixed pending receipt uploads, drained into the expense on submit */
public $receipts = [];
public bool $showCancelModal = false;
public bool $showRemoveModal = false;
/** Which attachment the remove dialog is about; null when it is closed. */
public ?int $removingAttachmentId = null;
public string $cancellationReason = '';
public function mount(string $uuid): void
......@@ -45,7 +50,7 @@ public function mount(string $uuid): void
private function expense(): Expense
{
$expense = Expense::with([
'creator', 'canceller', 'attachmentUploader', 'branch',
'creator', 'canceller', 'branch', 'attachments.uploader',
'transactions.debitAccount', 'transactions.creditAccount', 'transactions.creator',
])->where('uuid', $this->uuid)->firstOrFail();
......@@ -59,69 +64,107 @@ private function expense(): Expense
public function rules(): array
{
return [
'receipts' => 'required|array|min:1|max:' . config('uploads.max_files'),
// heic/heif are here because that is what an iPhone's camera roll
// hands over: the picker accepted the photo and validation then
// refused it, which read to the desk as "the upload is broken".
'receipt' => 'required|file|max:' . config('uploads.max_kb') . '|mimes:jpg,jpeg,png,pdf,webp,heic,heif,gif,bmp',
'receipts.*' => 'file|max:' . config('uploads.max_kb') . '|mimes:jpg,jpeg,png,pdf,webp,heic,heif,gif,bmp',
];
}
public function messages(): array
{
return [
'receipt.required' => 'اختر ملف الإيصال أولاً',
'receipt.max' => __('حجم الملف لا يتجاوز :mb ميجابايت', ['mb' => intdiv((int) config('uploads.max_kb'), 1024)]),
'receipt.mimes' => 'الملف يجب أن يكون صورة (jpg, png, webp, heic) أو PDF',
'receipts.required' => 'اختر ملف الإيصال أولاً',
'receipts.max' => __('لا يمكن إرفاق أكثر من :count ملف دفعة واحدة', ['count' => config('uploads.max_files')]),
'receipts.*.max' => __('حجم الملف لا يتجاوز :mb ميجابايت', ['mb' => intdiv((int) config('uploads.max_kb'), 1024)]),
'receipts.*.mimes' => 'الملف يجب أن يكون صورة (jpg, png, webp, heic) أو PDF',
];
}
public function uploadReceipt(ExpenseService $service): void
{
$this->authorize('expenses.create');
if (! $this->temporaryUploadIsUsable('receipt', $this->receipt)) {
$chosen = array_values(array_filter(is_array($this->receipts) ? $this->receipts : [$this->receipts]));
if (! $this->temporaryUploadIsUsable('receipts', $chosen)) {
return;
}
$this->receipts = $chosen;
$this->validate();
$expense = $this->expense();
$room = (int) config('uploads.max_files') - $expense->attachmentCount();
if ($room <= 0) {
$this->addError('receipts', __('هذا المصروف وصل إلى الحد الأقصى :count مرفق', ['count' => config('uploads.max_files')]));
return;
}
try {
// Read the metadata BEFORE storing. store() moves the file out of
// livewire-tmp, and every getter after it goes back to a path that
// no longer exists — getSize() threw Flysystem's
// UnableToRetrieveMetadata and the desk got an error page on every
// single receipt (1b62a44f-…, 2026-09-03). Array literals evaluate
// in order, so 'path' first was the whole bug.
$name = $this->receipt->getClientOriginalName();
$mime = $this->receipt->getMimeType();
$size = $this->receipt->getSize();
$service->attachReceipt($this->expense(), [
'path' => $this->receipt->store('expenses/receipts', 'local'),
'name' => $name,
'disk' => 'local',
'mime' => $mime,
'size' => $size,
], auth()->user());
$this->receipt = null;
session()->flash('success', __('تم إرفاق المستند بنجاح'));
$files = [];
foreach (array_slice($chosen, 0, $room) as $file) {
// Read the metadata BEFORE storing. store() moves the file out
// of livewire-tmp, and every getter after it goes back to a
// path that no longer exists — getSize() threw Flysystem's
// UnableToRetrieveMetadata and the desk got an error page on
// every single receipt (1b62a44f-…, 2026-09-03). Array literals
// evaluate in order, so 'path' first was the whole bug.
$name = $file->getClientOriginalName();
$mime = $file->getMimeType();
$size = $file->getSize();
$files[] = [
'path' => $file->store('expenses/receipts', 'local'),
'name' => $name,
'disk' => 'local',
'mime' => $mime,
'size' => $size,
];
}
$service->attachReceipts($expense, $files, auth()->user());
$this->receipts = [];
session()->flash('success', trans_choice(
'{1}تم إرفاق المستند بنجاح|[2,*]تم إرفاق :count مستندات بنجاح',
count($files),
['count' => count($files)],
));
} catch (DomainException $e) {
session()->flash('error', $e->getMessage());
}
}
public function confirmRemove(int $attachmentId): void
{
$this->authorize('expenses.create');
$this->removingAttachmentId = $attachmentId;
}
public function removeReceipt(ExpenseService $service): void
{
$this->authorize('expenses.create');
if (! $this->removingAttachmentId) {
return;
}
try {
$service->removeReceipt($this->expense(), auth()->user());
// The id came off the wire. removeReceipt() re-resolves it through
// this expense's own relation, so an id belonging to another
// expense — or another academy — deletes nothing.
$service->removeReceipt($this->expense(), $this->removingAttachmentId, auth()->user());
session()->flash('success', __('تم حذف المرفق'));
} catch (DomainException $e) {
session()->flash('error', $e->getMessage());
}
$this->showRemoveModal = false;
$this->removingAttachmentId = null;
}
public function confirmCancel(ExpenseService $service): void
......
......@@ -38,4 +38,19 @@
'icon_max_kb' => (int) env('UPLOAD_ICON_MAX_KB', 1024), // 1 MB
/*
|--------------------------------------------------------------------------
| How many files one record may carry
|--------------------------------------------------------------------------
|
| An expense is one payment, not one piece of paper: a month of fuel or a
| kit order settled across a dozen shops arrives as a single expense with a
| hundred receipts behind it. The ceiling exists only so a runaway
| selection cannot exhaust the request, not to express an opinion about how
| many receipts an expense ought to have.
|
*/
'max_files' => (int) env('UPLOAD_MAX_FILES', 200),
];
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;
use Illuminate\Support\Str;
/**
* One expense, many receipt images.
*
* The original design put a single set of `attachment_*` columns on
* `expenses`, which encoded an assumption that turned out to be wrong: a
* month's fuel, or a kit order paid across a dozen shops, arrives at the desk
* as one expense with a hundred paper receipts behind it. With one column set
* the second upload silently replaced the first — attachReceipt() deleted the
* previous file — so the evidence for an expense was whatever happened to be
* uploaded last.
*
* The columns on `expenses` are deliberately left in place rather than
* dropped. They are backfilled into this table below and stop being written,
* but a client DB is migrated in place on a live container and a dropped
* column is not recoverable if this needs backing out.
*/
return new class extends Migration
{
public function up(): void
{
if (!Schema::hasTable('expenses')) {
return;
}
if (!Schema::hasTable('expense_attachments')) {
Schema::create('expense_attachments', function (Blueprint $table) {
$table->id();
$table->uuid('uuid')->unique();
$table->foreignId('academy_id')->constrained('academies')->cascadeOnDelete();
$table->foreignId('expense_id')->constrained('expenses')->cascadeOnDelete();
$table->string('path', 500);
$table->string('name', 255);
$table->string('disk', 32)->default('local');
$table->string('mime', 128)->nullable();
$table->unsignedBigInteger('size')->nullable();
$table->foreignId('uploaded_by')->nullable()->constrained('users')->nullOnDelete();
$table->timestamp('uploaded_at')->nullable();
$table->timestamps();
// Every listing reads "the attachments of this expense, oldest
// first"; academy_id leads because it leads every other index
// in the schema and the global scope always constrains it.
$table->index(['academy_id', 'expense_id'], 'expense_attachments_academy_expense_idx');
});
}
// Carry the existing single attachment over. Idempotent by path: a
// re-run on a container that already migrated finds the row and skips
// it, which matters because entrypoint.sh runs migrate on every boot.
if (!Schema::hasColumn('expenses', 'attachment_path')) {
return;
}
DB::table('expenses')
->whereNotNull('attachment_path')
->orderBy('id')
->chunkById(200, function ($expenses) {
$rows = [];
foreach ($expenses as $expense) {
$exists = DB::table('expense_attachments')
->where('expense_id', $expense->id)
->where('path', $expense->attachment_path)
->exists();
if ($exists) {
continue;
}
$rows[] = [
'uuid' => (string) Str::uuid(),
'academy_id' => $expense->academy_id,
'expense_id' => $expense->id,
'path' => $expense->attachment_path,
'name' => $expense->attachment_name ?: basename($expense->attachment_path),
// Rows predating the metadata migration lived on the
// public disk; attachmentDiskName() said so in PHP and
// the backfill has to say the same thing here.
'disk' => $expense->attachment_disk ?: 'public',
'mime' => $expense->attachment_mime,
'size' => $expense->attachment_size,
'uploaded_by' => $expense->attachment_uploaded_by,
'uploaded_at' => $expense->attachment_uploaded_at ?: $expense->created_at,
'created_at' => $expense->created_at,
'updated_at' => $expense->updated_at,
];
}
if ($rows) {
DB::table('expense_attachments')->insert($rows);
}
});
}
public function down(): void
{
Schema::dropIfExists('expense_attachments');
}
};
......@@ -128,33 +128,51 @@ class="w-full rounded-lg border-gray-300 text-sm py-2.5">
class="w-full rounded-lg border-gray-300 text-sm py-2.5"></textarea>
</div>
{{-- Attachment --}}
{{-- Attachments --}}
<div class="md:col-span-2">
<label class="block text-sm text-gray-600 mb-1">{{ __('مرفق (إيصال / إثبات)') }}</label>
@if($attachment && method_exists($attachment, 'getClientOriginalName'))
<div class="flex items-center gap-3 p-3 bg-green-50 border border-green-200 rounded-lg mb-2">
<svg class="w-5 h-5 text-green-600 shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z"/>
</svg>
<span class="text-sm text-green-700 truncate">{{ $attachment->getClientOriginalName() }}</span>
<button type="button" wire:click="removeAttachment" class="ms-auto text-red-500 hover:text-red-700 text-xs">
{{ __('إزالة') }}
</button>
<label class="block text-sm text-gray-600 mb-1">
{{ __('المرفقات (إيصالات / إثباتات)') }}
@if(count($attachments))
<span class="text-gray-400">({{ count($attachments) }})</span>
@endif
</label>
@if(count($attachments))
<div class="mb-2 rounded-lg border border-green-200 bg-green-50/60 divide-y divide-green-100 max-h-64 overflow-y-auto">
@foreach($attachments as $i => $file)
<div class="flex items-center gap-3 px-3 py-2" wire:key="pending-{{ $i }}">
<svg class="w-4 h-4 text-green-600 shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z"/>
</svg>
<span class="text-sm text-green-800 truncate">{{ method_exists($file, 'getClientOriginalName') ? $file->getClientOriginalName() : __('ملف') }}</span>
<button type="button" wire:click="removeAttachment({{ $i }})"
class="ms-auto text-red-500 hover:text-red-700 text-xs shrink-0">
{{ __('إزالة') }}
</button>
</div>
@endforeach
</div>
<button type="button" wire:click="clearAttachments" class="mb-2 text-xs text-gray-500 hover:text-red-600">
{{ __('إزالة الكل') }}
</button>
@endif
<label class="flex flex-col items-center justify-center w-full h-28 border-2 border-dashed border-gray-300 rounded-lg cursor-pointer hover:border-amber-400 hover:bg-amber-50/50 transition-colors">
<div class="flex flex-col items-center">
<svg class="w-8 h-8 text-gray-400 mb-1" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M4 16l4.586-4.586a2 2 0 012.828 0L16 16m-2-2l1.586-1.586a2 2 0 012.828 0L20 14m-6-6h.01M6 20h12a2 2 0 002-2V6a2 2 0 00-2-2H6a2 2 0 00-2 2v12a2 2 0 002 2z"/>
</svg>
<span class="text-xs text-gray-500">{{ __('اضغط لرفع صورة أو ملف PDF (حد أقصى :mb ميجابايت)', ['mb' => intdiv((int) config('uploads.max_kb'), 1024)]) }}</span>
<span class="text-xs text-gray-500">{{ __('اضغط لاختيار صور أو ملفات PDF — يمكنك اختيار عدة ملفات معاً (حد أقصى :mb ميجابايت للملف)', ['mb' => intdiv((int) config('uploads.max_kb'), 1024)]) }}</span>
<span class="text-[11px] text-gray-400 mt-0.5">{{ __('يمكنك الرفع على دفعات — الملفات السابقة لا تُستبدل') }}</span>
</div>
{{-- image/* rather than an extension list: an extension list
greys out the camera roll on iOS and hides HEIC photos. --}}
<input type="file" wire:model="attachment" class="hidden" accept="image/*,.heic,.heif,application/pdf">
<input type="file" wire:model="incoming" multiple class="hidden" accept="image/*,.heic,.heif,application/pdf">
</label>
<div wire:loading wire:target="attachment" class="mt-1 text-xs text-amber-600">{{ __('جارٍ رفع الملف...') }}</div>
@error('attachment') <p class="text-red-500 text-xs mt-1">{{ $message }}</p> @enderror
<div wire:loading wire:target="incoming" class="mt-1 text-xs text-amber-600">{{ __('جارٍ رفع الملفات...') }}</div>
@error('attachments') <p class="text-red-500 text-xs mt-1">{{ $message }}</p> @enderror
@error('attachments.*') <p class="text-red-500 text-xs mt-1">{{ $message }}</p> @enderror
@error('incoming.*') <p class="text-red-500 text-xs mt-1">{{ $message }}</p> @enderror
</div>
</div>
......
......@@ -79,8 +79,16 @@ class="hover:bg-gray-50 cursor-pointer {{ $expense->status === 'cancelled' ? 'op
<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>
{{-- attachments_count comes from withCount() on the
list query; calling hasAttachment() here would be
one COUNT per row. --}}
@if($expense->attachments_count)
<span class="inline-flex items-center gap-0.5 shrink-0 text-gray-400" title="{{ __('عدد المرفقات') }}">
<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.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>
@if($expense->attachments_count > 1)
<span class="text-[10px]" dir="ltr">{{ $expense->attachments_count }}</span>
@endif
</span>
@endif
</div>
</td>
......
......@@ -263,9 +263,9 @@
->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'])
Route::get('/expenses/attachments/{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'])
Route::get('/expenses/attachments/{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');
......
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