diff --git a/app/Console/Commands/ImportInvoiceDocuments.php b/app/Console/Commands/ImportInvoiceDocuments.php new file mode 100644 index 0000000..e7bc748 --- /dev/null +++ b/app/Console/Commands/ImportInvoiceDocuments.php @@ -0,0 +1,227 @@ +argument('directory'), '/'); + + if (!is_dir($directory)) { + $this->error("Directory not found: {$directory}"); + return self::FAILURE; + } + + $files = collect(glob("{$directory}/*.pdf"))->sort()->values(); + + if ($files->isEmpty()) { + $this->line('No PDF files found.'); + return self::SUCCESS; + } + + $groups = $files + ->map(fn(string $path) => $this->parseFile($path)) + ->groupBy(fn(array $file) => $file['id'] ?? 'unrecognized'); + + foreach ($groups as $id => $group) { + $this->handleGroup($id, $group); + } + + $this->newLine(); + $summary = "Imported: {$this->imported}, skipped: {$this->skipped}, unmatched: {$this->unmatched}, conflicts: {$this->conflicts}"; + $this->line($this->option('dry-run') ? "{$summary} (dry run, nothing written)" : $summary); + + return self::SUCCESS; + } + + /** + * Parse a filename into its embedded invoice id and version marker + * + * @return array{path: string, filename: string, id: ?int, isVersion2: bool} + */ + private function parseFile(string $path): array + { + $filename = basename($path); + $id = null; + $isVersion2 = false; + + if (preg_match('/^\d{8}(\d{4})(-2)?/', $filename, $matches)) { + $id = (int) $matches[1]; + $isVersion2 = isset($matches[2]); + } + + return [ + 'path' => $path, + 'filename' => $filename, + 'id' => $id, + 'isVersion2' => $isVersion2, + ]; + } + + /** + * Handle all files that parsed to the same invoice id (or "unrecognized") + * + * @param Collection $group + */ + private function handleGroup(int|string $id, Collection $group): void + { + if ($id === 'unrecognized') { + foreach ($group as $file) { + $this->warn(" ✗ {$file['filename']}: unrecognized filename"); + } + $this->unmatched += $group->count(); + return; + } + + $ordered = $this->resolveVersionOrder($group); + + if ($ordered === null) { + foreach ($group as $file) { + $this->warn(" ✗ {$file['filename']}: conflict, multiple candidate files for invoice #{$id}"); + } + $this->conflicts++; + return; + } + + $invoice = Invoice::find($id); + + if (!$invoice) { + foreach ($ordered as $file) { + $this->warn(" ✗ {$file['filename']}: no invoice with id {$id}"); + } + $this->unmatched++; + return; + } + + if ($invoice->documents()->exists() && !$this->option('force')) { + $this->line(" – invoice #{$id} already has a document, skipping"); + $this->skipped++; + return; + } + + if ($this->option('force')) { + $invoice->documents->each->delete(); + } + + $baseTime = now(); + + foreach ($ordered as $file) { + if ($mismatch = $this->dateMismatch($invoice, $file['filename'])) { + $this->warn(" ⚠ {$file['filename']}: {$mismatch}"); + } + + $versionLabel = $file['isVersion2'] ? ' (version 2, latest)' : ''; + + if ($this->option('dry-run')) { + $this->line(" ✓ {$file['filename']} → invoice #{$id}{$versionLabel}"); + continue; + } + + $this->importFile($invoice, $file, $file['isVersion2'] ? $baseTime->copy()->addMinute() : $baseTime); + $this->line(" ✓ {$file['filename']} → invoice #{$id}{$versionLabel}"); + } + + $this->imported++; + } + + /** + * Validate a group of same-id files and return them in import order (plain + * version first, then version 2), or null if the group is ambiguous + * + * @param Collection $group + * @return array|null + */ + private function resolveVersionOrder(Collection $group): ?array + { + if ($group->count() === 1) { + return $group->values()->all(); + } + + $plain = $group->where('isVersion2', false); + $version2 = $group->where('isVersion2', true); + + if ($group->count() === 2 && $plain->count() === 1 && $version2->count() === 1) { + return [...$plain->values(), ...$version2->values()]; + } + + return null; + } + + /** + * Warn (non-fatally) when a filename's embedded date doesn't match the invoice's actual invoiced_at + */ + private function dateMismatch(Invoice $invoice, string $filename): ?string + { + if (!$invoice->invoiced_at) { + return null; + } + + try { + $filenameDate = Carbon::createFromFormat('Ymd', substr($filename, 0, 8))->toDateString(); + } catch (\Exception) { + return null; + } + + $invoicedDate = Carbon::parse($invoice->invoiced_at)->toDateString(); + + if ($filenameDate !== $invoicedDate) { + return "filename date {$filenameDate} doesn't match invoiced_at ({$invoicedDate})"; + } + + return null; + } + + /** + * Copy a source file into permanent per-invoice storage and attach it as a Document + * + * @param array{path: string, filename: string, id: ?int, isVersion2: bool} $file + */ + private function importFile(Invoice $invoice, array $file, Carbon $timestamp): void + { + $path = "documents/invoices/{$invoice->id}/" . Str::uuid() . '.pdf'; + Storage::disk('local')->put($path, file_get_contents($file['path'])); + + $invoice->documents()->create([ + 'disk' => 'local', + 'path' => $path, + 'filename' => $file['filename'], + 'mime_type' => 'application/pdf', + 'size' => filesize($file['path']), + 'created_at' => $timestamp, + 'updated_at' => $timestamp, + ]); + } +} diff --git a/app/Console/Commands/ImportQuoteDocuments.php b/app/Console/Commands/ImportQuoteDocuments.php new file mode 100644 index 0000000..916206f --- /dev/null +++ b/app/Console/Commands/ImportQuoteDocuments.php @@ -0,0 +1,199 @@ +argument('directory'), '/'); + + if (!is_dir($directory)) { + $this->error("Directory not found: {$directory}"); + return self::FAILURE; + } + + $files = collect(glob("{$directory}/*.pdf"))->sort()->values(); + + if ($files->isEmpty()) { + $this->line('No PDF files found.'); + return self::SUCCESS; + } + + $groups = $files + ->map(fn(string $path) => $this->parseFile($path)) + ->groupBy(fn(array $file) => $file['id'] ?? 'unrecognized'); + + foreach ($groups as $id => $group) { + $this->handleGroup($id, $group); + } + + $this->newLine(); + $summary = "Imported: {$this->imported}, skipped: {$this->skipped}, unmatched: {$this->unmatched}, conflicts: {$this->conflicts}"; + $this->line($this->option('dry-run') ? "{$summary} (dry run, nothing written)" : $summary); + + return self::SUCCESS; + } + + /** + * Parse a filename into its embedded project id and version marker + * + * @return array{path: string, filename: string, id: ?int, isVersion2: bool} + */ + private function parseFile(string $path): array + { + $filename = basename($path); + $id = null; + $isVersion2 = false; + + if (preg_match('/^\d{8}(\d{4})(-2)?/', $filename, $matches)) { + $id = (int) $matches[1]; + $isVersion2 = isset($matches[2]); + } + + return [ + 'path' => $path, + 'filename' => $filename, + 'id' => $id, + 'isVersion2' => $isVersion2, + ]; + } + + /** + * Handle all files that parsed to the same project id (or "unrecognized") + * + * @param Collection $group + */ + private function handleGroup(int|string $id, Collection $group): void + { + if ($id === 'unrecognized') { + foreach ($group as $file) { + $this->warn(" ✗ {$file['filename']}: unrecognized filename"); + } + $this->unmatched += $group->count(); + return; + } + + $ordered = $this->resolveVersionOrder($group); + + if ($ordered === null) { + foreach ($group as $file) { + $this->warn(" ✗ {$file['filename']}: conflict, multiple candidate files for project #{$id}"); + } + $this->conflicts++; + return; + } + + $project = Project::find($id); + + if (!$project) { + foreach ($ordered as $file) { + $this->warn(" ✗ {$file['filename']}: no project with id {$id}"); + } + $this->unmatched++; + return; + } + + if ($project->documents()->exists() && !$this->option('force')) { + $this->line(" – project #{$id} already has a document, skipping"); + $this->skipped++; + return; + } + + if ($this->option('force')) { + $project->documents->each->delete(); + } + + $baseTime = now(); + + foreach ($ordered as $file) { + $versionLabel = $file['isVersion2'] ? ' (version 2, latest)' : ''; + + if ($this->option('dry-run')) { + $this->line(" ✓ {$file['filename']} → project #{$id}{$versionLabel}"); + continue; + } + + $this->importFile($project, $file, $file['isVersion2'] ? $baseTime->copy()->addMinute() : $baseTime); + $this->line(" ✓ {$file['filename']} → project #{$id}{$versionLabel}"); + } + + $this->imported++; + } + + /** + * Validate a group of same-id files and return them in import order (plain + * version first, then version 2), or null if the group is ambiguous + * + * @param Collection $group + * @return array|null + */ + private function resolveVersionOrder(Collection $group): ?array + { + if ($group->count() === 1) { + return $group->values()->all(); + } + + $plain = $group->where('isVersion2', false); + $version2 = $group->where('isVersion2', true); + + if ($group->count() === 2 && $plain->count() === 1 && $version2->count() === 1) { + return [...$plain->values(), ...$version2->values()]; + } + + return null; + } + + /** + * Copy a source file into permanent per-project storage and attach it as a Document + * + * @param array{path: string, filename: string, id: ?int, isVersion2: bool} $file + */ + private function importFile(Project $project, array $file, Carbon $timestamp): void + { + $path = "documents/projects/{$project->id}/" . Str::uuid() . '.pdf'; + Storage::disk('local')->put($path, file_get_contents($file['path'])); + + $project->documents()->create([ + 'disk' => 'local', + 'path' => $path, + 'filename' => $file['filename'], + 'mime_type' => 'application/pdf', + 'size' => filesize($file['path']), + 'created_at' => $timestamp, + 'updated_at' => $timestamp, + ]); + } +} diff --git a/app/Filament/Relations/DocumentsRelationManager.php b/app/Filament/Relations/DocumentsRelationManager.php new file mode 100644 index 0000000..324925e --- /dev/null +++ b/app/Filament/Relations/DocumentsRelationManager.php @@ -0,0 +1,58 @@ +recordTitle(fn(Document $record) => $record->filename) + ->heading(trans_choice('document', 2)) + ->defaultSort('created_at', 'desc') + ->columns([ + TextColumn::make('filename') + ->label(__('filename')), + TextColumn::make('size') + ->label(__('size')) + ->formatStateUsing(fn(int $state): string => Number::fileSize($state)) + ->fontFamily(FontFamily::Mono), + TextColumn::make('created_at') + ->label(__('createdAt')) + ->dateTime('j. F Y, H:i:s'), + ]) + ->recordActions([ + Action::make('download') + ->label('') + ->icon('tabler-file-type-pdf') + ->action(fn(Document $record) => Storage::disk($record->disk)->download($record->path, $record->filename)), + Action::make('downloadAttachment') + ->label('') + ->icon('tabler-file-type-xml') + ->hidden(fn(Document $record) => !$record->attachment_path) + ->action(fn(Document $record) => Storage::disk($record->disk)->download($record->attachment_path, $record->attachment_filename)), + DeleteAction::make() + ->icon('tabler-trash') + ->label('') + ->requiresConfirmation(), + ]) + ->paginated(false); + } + + public static function getModelLabel(): string + { + return trans_choice('document', 1); + } +} diff --git a/app/Filament/Resources/ExpenseResource.php b/app/Filament/Resources/ExpenseResource.php index 3b4d727..2c966aa 100644 --- a/app/Filament/Resources/ExpenseResource.php +++ b/app/Filament/Resources/ExpenseResource.php @@ -6,6 +6,9 @@ use App\Filament\Resources\ExpenseResource\Pages\ListExpenses; use App\Models\Expense; use Closure; +use Illuminate\Support\Arr; +use Illuminate\Support\Facades\Storage; +use Filament\Actions\Action; use Filament\Actions\ActionGroup; use Filament\Actions\BulkActionGroup; use Filament\Actions\CreateAction; @@ -14,6 +17,8 @@ use Filament\Actions\EditAction; use Filament\Actions\ReplicateAction; use Filament\Forms\Components\DatePicker; +use Filament\Forms\Components\FileUpload; +use Filament\Forms\Components\Hidden; use Filament\Forms\Components\Select; use Filament\Forms\Components\Textarea; use Filament\Forms\Components\TextInput; @@ -23,6 +28,7 @@ use Filament\Schemas\Components\Grid; use Filament\Schemas\Components\Section; use Filament\Schemas\Components\Utilities\Get; +use Filament\Schemas\Components\Utilities\Set; use Filament\Schemas\Schema; use Filament\Support\Enums\Alignment; use Filament\Support\Enums\FontFamily; @@ -98,22 +104,39 @@ public static function table(Table $table): Table ->label(__('category')) ->options(ExpenseCategory::options()), ]) - ->recordActions( + ->recordActions([ + Action::make('bill') + ->label('') + ->tooltip(__('bill')) + ->icon('tabler-receipt') + ->hidden(fn(Expense $record) => !$record->documents()->exists()) + ->action(function (Expense $record) { + $document = $record->documents()->latest()->firstOrFail(); + return Storage::disk($document->disk)->download($document->path, $document->filename); + }), ActionGroup::make([ EditAction::make() ->icon('tabler-edit') ->schema(self::formFields(6, false)) ->slideOver() - ->modalWidth(Width::Large), + ->modalWidth(Width::Large) + ->using(function (array $data, Expense $record): void { + $record->update(Arr::except($data, ['bill', 'bill_original_name'])); + self::syncBillDocument($record, $data); + }), ReplicateAction::make() ->icon('tabler-copy') ->schema(self::formFields(6, false)) ->slideOver() - ->modalWidth(Width::Large), + ->modalWidth(Width::Large) + ->beforeReplicaSaved(function (Expense $replica) { + unset($replica->bill, $replica->bill_original_name); + }) + ->after(fn(array $data, Expense $replica) => self::syncBillDocument($replica, $data)), DeleteAction::make()->icon('tabler-trash')->requiresConfirmation(), ]) ->icon('tabler-dots-vertical'), - ) + ]) ->toolbarActions([ BulkActionGroup::make([ DeleteBulkAction::make()->icon('tabler-trash'), @@ -125,7 +148,8 @@ public static function table(Table $table): Table ->icon('tabler-plus') ->schema(self::formFields(6, false)) ->slideOver() - ->modalWidth(Width::Large), + ->modalWidth(Width::Large) + ->using(fn(array $data): Expense => self::createWithBill($data)), ]) ->emptyStateIcon('tabler-ban') ->defaultSort('expended_at', 'desc') @@ -263,10 +287,68 @@ public static function formFields(int $columns = 12, bool $useSection = true): a ->label(__('description')) ->maxLength(65535) ->columnSpanFull(), + FileUpload::make('bill') + ->label(__('bill')) + ->disk('local') + ->directory('documents/expenses') + ->acceptedFileTypes(['application/pdf', 'image/jpeg', 'image/png', 'image/webp']) + ->storeFileNamesIn('bill_original_name') + ->openable() + ->downloadable() + ->previewable(false) + ->afterStateHydrated(function (FileUpload $component, Set $set, ?Expense $record) { + $document = $record?->documents()->latest()->first(); + $component->state($document?->path); + $set('bill_original_name', $document?->filename); + }) + ->columnSpanFull(), + Hidden::make('bill_original_name'), ]; return $useSection ? [Section::make()->columnSpan($columns)->schema($fields)->columns($columns)] : [Grid::make()->columns($columns)->schema($fields)]; } + + /** + * Create an expense from form data and attach its bill, if one was uploaded. + * Used instead of the default CreateAction process because "bill"/"bill_original_name" + * are virtual form fields with no matching column on the expenses table. + */ + public static function createWithBill(array $data): Expense + { + $expense = Expense::create(Arr::except($data, ['bill', 'bill_original_name'])); + self::syncBillDocument($expense, $data); + + return $expense; + } + + /** + * Keep the expense's bill in sync with the "bill" form field: attach a new + * document when a file was uploaded or replaced, remove it when cleared, and + * leave the existing document alone when the field wasn't touched. + */ + public static function syncBillDocument(Expense $expense, array $data): void + { + $path = $data['bill'] ?? null; + $current = $expense->documents()->latest()->first(); + + if ($current?->path === $path) { + return; + } + + $current?->delete(); + + if ($path === null) { + return; + } + + $expense->documents()->create([ + 'disk' => config('filesystems.default'), + 'path' => $path, + 'filename' => $data['bill_original_name'] ?? basename($path), + 'mime_type' => Storage::mimeType($path) ?: 'application/octet-stream', + 'size' => Storage::size($path), + ]); + } } diff --git a/app/Filament/Resources/ExpenseResource/Pages/ListExpenses.php b/app/Filament/Resources/ExpenseResource/Pages/ListExpenses.php index 5d753b2..146f990 100644 --- a/app/Filament/Resources/ExpenseResource/Pages/ListExpenses.php +++ b/app/Filament/Resources/ExpenseResource/Pages/ListExpenses.php @@ -4,6 +4,7 @@ use App\Enums\ExpenseCategory; use App\Filament\Resources\ExpenseResource; +use App\Models\Expense; use Filament\Actions\CreateAction; use Filament\Resources\Pages\ListRecords; use Filament\Schemas\Components\Tabs\Tab; @@ -35,7 +36,8 @@ protected function getHeaderActions(): array ->icon('tabler-plus') ->schema(ExpenseResource::formFields(6, false)) ->slideOver() - ->modalWidth(Width::Large), + ->modalWidth(Width::Large) + ->using(fn(array $data): Expense => ExpenseResource::createWithBill($data)), ]; } } diff --git a/app/Filament/Resources/InvoiceResource.php b/app/Filament/Resources/InvoiceResource.php index 70e5bf8..beee4c6 100644 --- a/app/Filament/Resources/InvoiceResource.php +++ b/app/Filament/Resources/InvoiceResource.php @@ -4,6 +4,7 @@ use App\Enums\InvoiceStatus; use App\Enums\PricingUnit; +use App\Filament\Relations\DocumentsRelationManager; use App\Filament\Relations\PositionsRelationManager; use App\Filament\Resources\InvoiceResource\Pages\EditInvoice; use App\Filament\Resources\InvoiceResource\Pages\ListInvoices; @@ -146,7 +147,25 @@ public static function table(Table $table): Table ->label(trans_choice('client', 1)) ->relationship('project.client', 'name'), ]) - ->recordActions( + ->recordActions([ + Action::make('pdf') + ->label('') + ->tooltip(__('downloadFiletype', ['type' => 'pdf'])) + ->icon('tabler-file-type-pdf') + ->hidden(fn(Invoice $record) => !$record->documents()->exists()) + ->action(function (Invoice $record) { + $document = $record->documents()->latest()->firstOrFail(); + return Storage::disk($document->disk)->download($document->path, $document->filename); + }), + Action::make('xml') + ->label('') + ->tooltip(__('downloadFiletype', ['type' => 'xml'])) + ->icon('tabler-file-type-xml') + ->hidden(fn(Invoice $record) => !$record->documents()->whereNotNull('attachment_path')->exists()) + ->action(function (Invoice $record) { + $document = $record->documents()->whereNotNull('attachment_path')->latest()->firstOrFail(); + return Storage::disk($document->disk)->download($document->attachment_path, $document->attachment_filename); + }), ActionGroup::make([ EditAction::make()->icon('tabler-edit')->slideOver()->modalWidth(Width::Large), ReplicateAction::make() @@ -158,21 +177,12 @@ public static function table(Table $table): Table ->schema(self::formFields(6, false)) ->slideOver() ->modalWidth(Width::ExtraLarge), - Action::make('pdf') - ->label(__('downloadFiletype', ['type' => 'pdf'])) - ->icon('tabler-file-type-pdf') + Action::make('generate') + ->label(__('generateInvoiceDocument')) + ->icon('tabler-file-plus') ->action(function (Invoice $record) { - Storage::delete(Storage::allFiles()); - $file = InvoiceService::generatePdf($record); - return response()->download(Storage::path($file)); - }), - Action::make('xml') - ->label(__('downloadFiletype', ['type' => 'xml'])) - ->icon('tabler-file-type-xml') - ->action(function (Invoice $record) { - Storage::delete(Storage::allFiles()); - $file = InvoiceService::generateEn16931Xml($record); - return response()->download(Storage::path($file)); + InvoiceService::generateDocuments($record); + Notification::make()->title(__('invoiceDocumentGenerated'))->success()->send(); }), Action::make('send') ->label(__('send')) @@ -219,7 +229,7 @@ public static function table(Table $table): Table DeleteAction::make()->icon('tabler-trash')->requiresConfirmation(), ]) ->icon('tabler-dots-vertical'), - ) + ]) ->toolbarActions([ BulkActionGroup::make([ DeleteBulkAction::make()->icon('tabler-trash'), @@ -238,6 +248,7 @@ public static function getRelations(): array { return [ PositionsRelationManager::class, + DocumentsRelationManager::class, ]; } diff --git a/app/Filament/Resources/InvoiceResource/Pages/EditInvoice.php b/app/Filament/Resources/InvoiceResource/Pages/EditInvoice.php index da224e0..01e07ec 100644 --- a/app/Filament/Resources/InvoiceResource/Pages/EditInvoice.php +++ b/app/Filament/Resources/InvoiceResource/Pages/EditInvoice.php @@ -10,6 +10,7 @@ use App\Services\InvoiceService; use Filament\Actions\Action; use Filament\Actions\DeleteAction; +use Filament\Notifications\Notification; use Filament\Resources\Pages\EditRecord; use Illuminate\Support\Facades\Storage; @@ -25,21 +26,28 @@ public function getFooterWidgetsColumns(): int|array protected function getHeaderActions(): array { return [ + Action::make('generate') + ->label(__('generateInvoiceDocument')) + ->icon('tabler-file-plus') + ->action(function (Invoice $record) { + InvoiceService::generateDocuments($record); + Notification::make()->title(__('invoiceDocumentGenerated'))->success()->send(); + }), Action::make('pdf') ->label(__('downloadFiletype', ['type' => 'pdf'])) ->icon('tabler-file-type-pdf') + ->disabled(fn(Invoice $record) => !$record->documents()->exists()) ->action(function (Invoice $record) { - Storage::delete(Storage::allFiles()); - $file = InvoiceService::generatePdf($record); - return response()->download(Storage::path($file)); + $document = $record->documents()->latest()->firstOrFail(); + return Storage::disk($document->disk)->download($document->path, $document->filename); }), Action::make('xml') ->label(__('downloadFiletype', ['type' => 'xml'])) ->icon('tabler-file-type-xml') + ->disabled(fn(Invoice $record) => !$record->documents()->whereNotNull('attachment_path')->exists()) ->action(function (Invoice $record) { - Storage::delete(Storage::allFiles()); - $file = InvoiceService::generateEn16931Xml($record); - return response()->download(Storage::path($file)); + $document = $record->documents()->whereNotNull('attachment_path')->latest()->firstOrFail(); + return Storage::disk($document->disk)->download($document->attachment_path, $document->attachment_filename); }), DeleteAction::make()->icon('tabler-trash')->requiresConfirmation(), ]; diff --git a/app/Filament/Resources/ProjectResource.php b/app/Filament/Resources/ProjectResource.php index f1c6a77..de4fc4c 100644 --- a/app/Filament/Resources/ProjectResource.php +++ b/app/Filament/Resources/ProjectResource.php @@ -3,6 +3,7 @@ namespace App\Filament\Resources; use App\Enums\PricingUnit; +use App\Filament\Relations\DocumentsRelationManager; use App\Filament\Relations\EstimatesRelationManager; use App\Filament\Relations\InvoicesRelationManager; use App\Filament\Resources\ProjectResource\Pages\EditProject; @@ -23,6 +24,7 @@ use Filament\Forms\Components\Textarea; use Filament\Forms\Components\TextInput; use Filament\Forms\Components\Toggle; +use Filament\Notifications\Notification; use Filament\Resources\Resource; use Filament\Schemas\Components\Grid; use Filament\Schemas\Components\Section; @@ -101,7 +103,16 @@ public static function table(Table $table): Table ->label(trans_choice('client', 1)) ->relationship('client', 'name'), ]) - ->recordActions( + ->recordActions([ + Action::make('pdf') + ->label('') + ->tooltip(__('downloadFiletype', ['type' => 'pdf'])) + ->icon('tabler-file-type-pdf') + ->hidden(fn(Project $record) => !$record->documents()->exists()) + ->action(function (Project $record) { + $document = $record->documents()->latest()->firstOrFail(); + return Storage::disk($document->disk)->download($document->path, $document->filename); + }), ActionGroup::make([ EditAction::make()->icon('tabler-edit'), ReplicateAction::make() @@ -114,18 +125,17 @@ public static function table(Table $table): Table ->schema(self::formFields(6, false)) ->slideOver() ->modalWidth(Width::Large), - Action::make('download') - ->label(__('quote')) - ->icon('tabler-file-type-pdf') + Action::make('generate') + ->label(__('generateQuote')) + ->icon('tabler-file-plus') ->action(function (Project $record) { - Storage::delete(Storage::allFiles()); - $file = ProjectService::generateQuotePdf($record); - return response()->download(Storage::path($file)); + ProjectService::generateDocument($record); + Notification::make()->title(__('quoteGenerated'))->success()->send(); }), DeleteAction::make()->icon('tabler-trash')->requiresConfirmation(), ]) ->icon('tabler-dots-vertical'), - ) + ]) ->toolbarActions([ BulkActionGroup::make([ DeleteBulkAction::make()->icon('tabler-trash'), @@ -149,6 +159,7 @@ public static function getRelations(): array return [ EstimatesRelationManager::class, InvoicesRelationManager::class, + DocumentsRelationManager::class, ]; } diff --git a/app/Filament/Resources/ProjectResource/Pages/EditProject.php b/app/Filament/Resources/ProjectResource/Pages/EditProject.php index 9a53c77..5476eff 100644 --- a/app/Filament/Resources/ProjectResource/Pages/EditProject.php +++ b/app/Filament/Resources/ProjectResource/Pages/EditProject.php @@ -7,6 +7,7 @@ use App\Services\ProjectService; use Filament\Actions\Action; use Filament\Actions\DeleteAction; +use Filament\Notifications\Notification; use Filament\Resources\Pages\EditRecord; use Illuminate\Support\Facades\Storage; @@ -17,13 +18,20 @@ class EditProject extends EditRecord protected function getHeaderActions(): array { return [ - Action::make('download') - ->label(__('quote')) + Action::make('generate') + ->label(__('generateQuote')) + ->icon('tabler-file-plus') + ->action(function (Project $record) { + ProjectService::generateDocument($record); + Notification::make()->title(__('quoteGenerated'))->success()->send(); + }), + Action::make('pdf') + ->label(__('downloadFiletype', ['type' => 'pdf'])) ->icon('tabler-file-type-pdf') + ->disabled(fn(Project $record) => !$record->documents()->exists()) ->action(function (Project $record) { - Storage::delete(Storage::allFiles()); - $file = ProjectService::generateQuotePdf($record); - return response()->download(Storage::path($file)); + $document = $record->documents()->latest()->firstOrFail(); + return Storage::disk($document->disk)->download($document->path, $document->filename); }), DeleteAction::make()->icon('tabler-trash'), ]; diff --git a/app/Filament/Widgets/MinorAssetsList.php b/app/Filament/Widgets/MinorAssetsList.php index a18524c..89c4400 100644 --- a/app/Filament/Widgets/MinorAssetsList.php +++ b/app/Filament/Widgets/MinorAssetsList.php @@ -6,12 +6,14 @@ use App\Models\Expense; use App\Models\Invoice; use Carbon\Carbon; +use Filament\Actions\Action; use Filament\Support\Enums\Alignment; use Filament\Support\Enums\FontFamily; use Filament\Tables\Columns\TextColumn; use Filament\Tables\Table; use Filament\Widgets\TableWidget; use Illuminate\Support\Collection; +use Illuminate\Support\Facades\Storage; class MinorAssetsList extends TableWidget { @@ -27,6 +29,7 @@ public function __construct() public function table(Table $table): Table { return $table + ->query(fn() => Expense::query()->where('category', ExpenseCategory::MinorAssets)) ->header(view('filament.widgets.table-header', [ 'heading' => __('minorAssetsRegister'), 'description' => __('minorAssetsRegisterDescription', [ @@ -71,6 +74,17 @@ public function table(Table $table): Table ->fontFamily(FontFamily::Mono) ->state(fn(Expense $record): float => $record->deductibleNet) ->alignRight(), + ]) + ->recordActions([ + Action::make('bill') + ->label('') + ->tooltip(__('bill')) + ->icon('tabler-receipt') + ->hidden(fn(Expense $record) => !$record->documents()->exists()) + ->action(function (Expense $record) { + $document = $record->documents()->latest()->firstOrFail(); + return Storage::disk($document->disk)->download($document->path, $document->filename); + }), ]); } diff --git a/app/Models/Document.php b/app/Models/Document.php new file mode 100644 index 0000000..de550ac --- /dev/null +++ b/app/Models/Document.php @@ -0,0 +1,62 @@ +morphTo(); + } + + protected static function booted(): void + { + static::deleting(function (self $document) { + Storage::disk($document->disk)->delete($document->path); + if ($document->attachment_path) { + Storage::disk($document->disk)->delete($document->attachment_path); + } + }); + } + + protected function casts(): array + { + return [ + 'size' => 'integer', + 'attachment_size' => 'integer', + 'created_at' => 'datetime', + 'updated_at' => 'datetime', + ]; + } + + /** + * File extension derived from the stored filename. + */ + protected function extension(): Attribute + { + return Attribute::make(fn(): string => strtolower(pathinfo($this->filename, PATHINFO_EXTENSION))); + } +} diff --git a/app/Models/Expense.php b/app/Models/Expense.php index 7f9951d..849ff7e 100644 --- a/app/Models/Expense.php +++ b/app/Models/Expense.php @@ -6,6 +6,7 @@ use App\Enums\TimeUnit; use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Model; +use Illuminate\Database\Eloquent\Relations\MorphMany; use Carbon\Carbon; use Illuminate\Database\Eloquent\Casts\Attribute; @@ -24,6 +25,14 @@ class Expense extends Model 'description', ]; + /** + * The documents attached to this expense. + */ + public function documents(): MorphMany + { + return $this->morphMany(Document::class, 'documentable'); + } + public static function lastAdvanceVatExists(): bool { $format = 'UStVA ' . now()->year . '-' . now()->subMonth()->isoFormat('MM'); diff --git a/app/Models/Invoice.php b/app/Models/Invoice.php index f14409b..176990c 100644 --- a/app/Models/Invoice.php +++ b/app/Models/Invoice.php @@ -11,6 +11,7 @@ use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Relations\BelongsTo; use Illuminate\Database\Eloquent\Relations\HasMany; +use Illuminate\Database\Eloquent\Relations\MorphMany; use Illuminate\Support\Number; use Carbon\Carbon; use Illuminate\Database\Eloquent\Casts\Attribute; @@ -50,6 +51,14 @@ public function positions(): HasMany return $this->hasMany(Position::class); } + /** + * The documents attached to this invoice. + */ + public function documents(): MorphMany + { + return $this->morphMany(Document::class, 'documentable'); + } + /** * Calculate array holding all years having paid invoices * sorted from current to past diff --git a/app/Models/Project.php b/app/Models/Project.php index 1764f8c..81d1257 100644 --- a/app/Models/Project.php +++ b/app/Models/Project.php @@ -11,6 +11,7 @@ use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Relations\BelongsTo; use Illuminate\Database\Eloquent\Relations\HasMany; +use Illuminate\Database\Eloquent\Relations\MorphMany; use Illuminate\Support\HtmlString; class Project extends Model @@ -53,6 +54,14 @@ public function invoices(): HasMany return $this->hasMany(Invoice::class); } + /** + * The documents attached to this project. + */ + public function documents(): MorphMany + { + return $this->morphMany(Document::class, 'documentable'); + } + protected function casts(): array { return [ diff --git a/app/Services/InvoiceService.php b/app/Services/InvoiceService.php index 250ab7f..7df535b 100644 --- a/app/Services/InvoiceService.php +++ b/app/Services/InvoiceService.php @@ -4,6 +4,7 @@ use App\Enums\DocumentColor as Color; use App\Enums\PricingUnit; +use App\Models\Document; use App\Models\Invoice; use App\Models\Setting; use Carbon\Carbon; @@ -14,6 +15,7 @@ use fpdf\Enums\PdfTextAlignment; use Illuminate\Support\Facades\Storage; use Illuminate\Support\Number; +use Illuminate\Support\Str; use XMLWriter; class InvoiceService @@ -311,6 +313,20 @@ public static function generatePdf(Invoice $invoice): string return $filename; } + /** + * Generate the invoice PDF and XML and permanently attach both to the invoice as a single document + */ + public static function generateDocuments(Invoice $invoice): Document + { + return self::persistDocument( + $invoice, + self::generatePdf($invoice), + 'application/pdf', + self::generateEn16931Xml($invoice), + 'application/xml', + ); + } + /** * Generate invoice XML (EN16931 conform), save it and return path/filename * @see https://validator.invoice-portal.de for validation check @@ -507,4 +523,42 @@ public static function generateEn16931Xml(Invoice $invoice): string unset($x); return $filename; } + + /** + * Move a generated scratch file into permanent per-document storage and attach it to the invoice + */ + private static function persistDocument( + Invoice $invoice, + string $scratchFile, + string $mimeType, + ?string $attachmentScratchFile = null, + ?string $attachmentMimeType = null, + ): Document { + $path = self::moveToPermanentStorage($invoice, $scratchFile); + $attachmentPath = $attachmentScratchFile ? self::moveToPermanentStorage($invoice, $attachmentScratchFile) : null; + + return $invoice->documents()->create([ + 'disk' => config('filesystems.default'), + 'path' => $path, + 'filename' => $scratchFile, + 'mime_type' => $mimeType, + 'size' => Storage::size($path), + 'attachment_path' => $attachmentPath, + 'attachment_filename' => $attachmentScratchFile, + 'attachment_mime_type' => $attachmentPath ? $attachmentMimeType : null, + 'attachment_size' => $attachmentPath ? Storage::size($attachmentPath) : null, + ]); + } + + /** + * Move a generated scratch file into permanent per-invoice document storage + */ + private static function moveToPermanentStorage(Invoice $invoice, string $scratchFile): string + { + $extension = pathinfo($scratchFile, PATHINFO_EXTENSION); + $path = "documents/invoices/{$invoice->id}/" . Str::uuid() . ".{$extension}"; + Storage::move($scratchFile, $path); + + return $path; + } } diff --git a/app/Services/ProjectService.php b/app/Services/ProjectService.php index 2f59ad7..e26c30f 100644 --- a/app/Services/ProjectService.php +++ b/app/Services/ProjectService.php @@ -5,6 +5,7 @@ use App\Enums\DocumentColor as Color; use App\Enums\DocumentType; use App\Enums\PricingUnit; +use App\Models\Document; use App\Models\Project; use App\Models\Setting; use Carbon\Carbon; @@ -14,6 +15,7 @@ use fpdf\Enums\PdfTextAlignment; use Illuminate\Support\Facades\Storage; use Illuminate\Support\Number; +use Illuminate\Support\Str; class ProjectService { @@ -313,4 +315,23 @@ public static function generateQuotePdf(Project $project): string $pdf->output(PdfDestination::FILE, Storage::path($filename)); return $filename; } + + /** + * Generate the quote PDF and permanently attach it to the project as a document + */ + public static function generateDocument(Project $project): Document + { + $scratchFile = self::generateQuotePdf($project); + $extension = pathinfo($scratchFile, PATHINFO_EXTENSION); + $path = "documents/projects/{$project->id}/" . Str::uuid() . ".{$extension}"; + Storage::move($scratchFile, $path); + + return $project->documents()->create([ + 'disk' => config('filesystems.default'), + 'path' => $path, + 'filename' => $scratchFile, + 'mime_type' => 'application/pdf', + 'size' => Storage::size($path), + ]); + } } diff --git a/database/factories/DocumentFactory.php b/database/factories/DocumentFactory.php new file mode 100644 index 0000000..67e23bf --- /dev/null +++ b/database/factories/DocumentFactory.php @@ -0,0 +1,40 @@ + + */ +class DocumentFactory extends Factory +{ + /** + * The name of the factory's corresponding model. + * + * @var string + */ + protected $model = Document::class; + + /** + * Define the model's default state. + * + * @return array + */ + public function definition(): array + { + $filename = fake()->word() . '.pdf'; + + return [ + 'documentable_type' => Invoice::class, + 'documentable_id' => Invoice::factory(), + 'disk' => 'local', + 'path' => 'documents/' . fake()->uuid() . '.pdf', + 'filename' => $filename, + 'mime_type' => 'application/pdf', + 'size' => fake()->numberBetween(1_000, 500_000), + ]; + } +} diff --git a/database/migrations/2026_09_11_000001_create_documents_table.php b/database/migrations/2026_09_11_000001_create_documents_table.php new file mode 100644 index 0000000..dc9bf0c --- /dev/null +++ b/database/migrations/2026_09_11_000001_create_documents_table.php @@ -0,0 +1,36 @@ +id(); + $table->morphs('documentable'); + $table->string('disk'); + $table->string('path'); + $table->string('filename'); + $table->string('mime_type'); + $table->unsignedInteger('size'); + $table->string('attachment_path')->nullable(); + $table->string('attachment_filename')->nullable(); + $table->string('attachment_mime_type')->nullable(); + $table->unsignedInteger('attachment_size')->nullable(); + $table->timestamps(); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('documents'); + } +}; diff --git a/lang/de.json b/lang/de.json index 7182d73..13345e6 100644 --- a/lang/de.json +++ b/lang/de.json @@ -13,6 +13,7 @@ "averageWorkingHours": "Anzahl Arbeitswochen und durchschnittliche Stunden pro Arbeitswoche", "bank": "Kreditinstitut", "bic": "BIC", + "bill": "Beleg", "category": "Kategorie", "city": "Stadt", "clickForLatestInvoice": "Klicken um zur aktuellen Rechnung zu springen", @@ -47,6 +48,7 @@ "disclaimer": "Haftungsausschluss", "disclaimerText": "Der Auftragnehmer haftet nur für Schäden des Auftraggebers, wenn sie auf grob fahrlässigem oder vorsätzlichem Verhalten oder auf der Verletzung einer wesentlichen Vertragspflicht beruhen. Im Falle der leicht fahrlässigen Verletzung einer wesentlichen Vertragspflicht ist die Haftung der Höhe nach auf den bei Vertragsbeginn vorhersehbaren, typischerweise eintretenden Schaden begrenzt. Eine Haftung für entgangenen Gewinn, Ansprüche Dritter, ausgebliebene Einsparungen, Datenerfassungs- und Verarbeitungsaufwand ist ausgeschlossen. Ansprüche wegen der Verletzung von Leben, Körper oder Gesundheit oder nach dem Produkthaftungsgesetz bleiben von den vorstehenden Haftungsbeschränkungen unberührt.", "discount": "Rabatt", + "document": "Dokument|Dokumente", "download": "Download", "downloadFiletype": ":TYPE Download", "downloadStartsAutomatically": "Der Download startet automatisch...", @@ -63,6 +65,7 @@ "expendedAt": "Ausgegeben", "expense": "Ausgabe|Ausgaben", "field": "Feld", + "filename": "Dateiname", "finished": "Beendet", "finishedAt": "Beendet am", "flatRate": "pauschal", @@ -83,6 +86,8 @@ "vr118": "Vorauszahlungssoll", "vr75": "Übrige nicht steuerbare Umsätze (Leistungsort nicht im Inland)" }, + "generateInvoiceDocument": "Rechnung generieren", + "generateQuote": "Angebot generieren", "gift": "Schenkung|Schenkungen", "good": "Ware", "gross": "Brutto", @@ -112,6 +117,7 @@ "invoiceDate": "Rechnungsdatum", "invoiceDates": "Rechnungsdaten", "invoiceDateSet": "Rechnungsdatum gesetzt", + "invoiceDocumentGenerated": "Rechnungsdokument erstellt", "invoiceIssued": "Rechnung gestellt", "invoiceNumber": "Rechnungsnummer", "invoicePaid": "Rechnung beglichen", @@ -172,6 +178,7 @@ "quarter": "Quartal", "quote": "Angebot", "quote.explanation": "Ich bedanke mich für das Interesse und freue mich auf die Zusammenarbeit! ", + "quoteGenerated": "Angebot generiert", "receivedAt": "Erhalten am", "referenceUse": "Referenznutzung", "referenceUseText": "Der Auftragnehmer ist berechtigt, den Auftraggeber in Text (insb. Firmenname) und Bild (insbes. Firmenlogo) als Referenzkunde in einer Referenzliste oder Presseinformation anzugeben.", @@ -190,6 +197,7 @@ "short": "Abkürzung", "sick": "Krank", "signature": "Unterschrift", + "size": "Größe", "startAt": "Beginn", "startedAt": "Begonnen am", "statementOfWork": "Tätigkeit und Leistungsbeschreibung", diff --git a/lang/en.json b/lang/en.json index cb71b5c..0afc342 100644 --- a/lang/en.json +++ b/lang/en.json @@ -13,6 +13,7 @@ "averageWorkingHours": "Working weeks count and average hours per working week", "bank": "Credit Institution", "bic": "BIC", + "bill": "Bill", "category": "Category", "city": "City", "clickForLatestInvoice": "Click to jump to open invoice", @@ -47,6 +48,7 @@ "disclaimer": "Disclaimer", "disclaimerText": "The Contractor shall only be liable for damages to the Client if they are based on grossly negligent or intentional behaviour or on the breach of a material contractual obligation. In the event of a slightly negligent breach of a material contractual obligation, the amount of liability shall be limited to the foreseeable, typically occurring damage at the start of the contract. Liability for loss of profit, third-party claims, lost savings, data acquisition and processing costs is excluded. Claims for injury to life, limb or health or under the Product Liability Act remain unaffected by the above limitations of liability.", "discount": "Discount", + "document": "Document|Documents", "download": "Download", "downloadFiletype": "Download :TYPE", "downloadStartsAutomatically": "Download starts automatically...", @@ -63,6 +65,7 @@ "expendedAt": "Expended at", "expense": "Expense|Expenses", "field": "Field", + "filename": "Filename", "finished": "Finished", "finishedAt": "Finished at", "flatRate": "flat-rate", @@ -83,6 +86,8 @@ "vr118": "Prepayment target", "vr75": "Other non-taxable transactions (place of performance not in the country)" }, + "generateInvoiceDocument": "Generate invoice document", + "generateQuote": "Generate quote", "gift": "Gift|Gifts", "good": "Good", "gross": "Gross", @@ -112,6 +117,7 @@ "invoiceDate": "Invoice Date", "invoiceDates": "Invoice dates", "invoiceDateSet": "Invoice date set", + "invoiceDocumentGenerated": "Invoice document generated", "invoiceIssued": "Invoice issued", "invoiceNumber": "Invoice Number", "invoicePaid": "Invoice paid", @@ -172,6 +178,7 @@ "quarter": "Quarter", "quote": "Quote", "quote.explanation": "Thank you for your interest and I look forward to working with you! ", + "quoteGenerated": "Quote generated", "receivedAt": "Received at", "referenceUse": "Reference Usage", "referenceUseText": "The Contractor is authorised to name the Client in text (in particular company name) and image (in particular company logo) as a reference customer in a reference list or press release.", @@ -190,6 +197,7 @@ "short": "Short", "sick": "Sick", "signature": "Signature", + "size": "Size", "startAt": "Start at", "startedAt": "Started at", "statementOfWork": "Statement of Work", diff --git a/tests/Feature/ExpenseResourceTest.php b/tests/Feature/ExpenseResourceTest.php index a42f40c..563e4d6 100644 --- a/tests/Feature/ExpenseResourceTest.php +++ b/tests/Feature/ExpenseResourceTest.php @@ -5,13 +5,17 @@ use App\Enums\ExpenseCategory; use App\Filament\Resources\ExpenseResource; use App\Filament\Resources\ExpenseResource\Pages\ListExpenses; +use App\Models\Document; use App\Models\Expense; use App\Models\User; use Filament\Actions\CreateAction; use Filament\Actions\DeleteAction; use Filament\Actions\EditAction; +use Filament\Actions\ReplicateAction; use Filament\Actions\Testing\TestAction; use Illuminate\Foundation\Testing\RefreshDatabase; +use Illuminate\Http\UploadedFile; +use Illuminate\Support\Facades\Storage; use Livewire\Livewire; use PHPUnit\Framework\Attributes\Test; use Tests\TestCase; @@ -207,4 +211,125 @@ public function it_deletes_an_expense_from_the_table(): void $this->assertModelMissing($expense); } + + #[Test] + public function it_shows_the_original_filename_when_editing_an_expense_with_a_bill(): void + { + $this->actingAs(User::factory()->create()); + $expense = Expense::factory()->create(['category' => ExpenseCategory::Good]); + Document::factory()->for($expense, 'documentable')->create(['filename' => 'receipt.pdf']); + + Livewire::test(ListExpenses::class, ['activeTab' => 'all']) + ->mountAction(TestAction::make(EditAction::class)->table($expense)) + ->assertActionDataSet(['bill_original_name' => 'receipt.pdf']); + } + + #[Test] + public function it_hides_the_bill_download_in_the_table_until_a_document_exists(): void + { + $this->actingAs(User::factory()->create()); + $expense = Expense::factory()->create(['category' => ExpenseCategory::Good]); + + Livewire::test(ListExpenses::class, ['activeTab' => 'all']) + ->assertTableActionHidden('bill', $expense); + + Document::factory()->for($expense, 'documentable')->create(); + + Livewire::test(ListExpenses::class, ['activeTab' => 'all']) + ->assertTableActionVisible('bill', $expense); + } + + #[Test] + public function it_attaches_a_bill_document_when_creating_an_expense(): void + { + $this->actingAs(User::factory()->create()); + $file = UploadedFile::fake()->create('receipt.pdf', 10, 'application/pdf'); + + Livewire::test(ListExpenses::class) + ->callAction(CreateAction::class, data: [ + 'expended_at' => '2026-01-15', + 'category' => ExpenseCategory::Good->value, + 'price' => 42.5, + 'quantity' => 1, + 'taxable' => false, + 'bill' => $file, + ]) + ->assertHasNoFormErrors(); + + $expense = Expense::sole(); + $document = $expense->documents()->sole(); + + $this->assertSame('receipt.pdf', $document->filename); + Storage::assertExists($document->path); + } + + #[Test] + public function it_replaces_the_bill_document_when_uploading_a_new_one(): void + { + $expense = Expense::factory()->create(['category' => ExpenseCategory::Good]); + $original = Document::factory()->for($expense, 'documentable')->create(['path' => 'documents/old.pdf']); + Storage::put('documents/old.pdf', 'old content'); + Storage::put('documents/new.pdf', 'new content'); + + ExpenseResource::syncBillDocument($expense, ['bill' => 'documents/new.pdf', 'bill_original_name' => 'new-receipt.pdf']); + + $this->assertModelMissing($original); + Storage::assertMissing('documents/old.pdf'); + $document = $expense->documents()->sole(); + $this->assertSame('documents/new.pdf', $document->path); + $this->assertSame('new-receipt.pdf', $document->filename); + Storage::assertExists($document->path); + } + + #[Test] + public function it_removes_the_bill_document_when_the_upload_is_cleared(): void + { + $this->actingAs(User::factory()->create()); + $expense = Expense::factory()->create(['category' => ExpenseCategory::Good]); + Document::factory()->for($expense, 'documentable')->create(); + + Livewire::test(ListExpenses::class, ['activeTab' => 'all']) + ->callAction(TestAction::make(EditAction::class)->table($expense), data: ['bill' => null]) + ->assertHasNoFormErrors(); + + $this->assertSame(0, $expense->documents()->count()); + } + + #[Test] + public function it_keeps_the_bill_document_untouched_when_updating_other_fields(): void + { + $this->actingAs(User::factory()->create()); + $expense = Expense::factory()->create(['category' => ExpenseCategory::Good]); + $document = Document::factory()->for($expense, 'documentable')->create(); + + Livewire::test(ListExpenses::class, ['activeTab' => 'all']) + ->callAction(TestAction::make(EditAction::class)->table($expense), data: ['description' => 'New description']) + ->assertHasNoFormErrors(); + + $this->assertSame(1, $expense->documents()->count()); + $this->assertTrue($expense->documents()->sole()->is($document)); + } + + #[Test] + public function it_replicates_an_expense_without_carrying_over_its_bill_document(): void + { + $this->actingAs(User::factory()->create()); + $expense = Expense::factory()->create(['category' => ExpenseCategory::Good]); + Document::factory()->for($expense, 'documentable')->create(); + + Livewire::test(ListExpenses::class, ['activeTab' => 'all']) + ->callAction(TestAction::make(ReplicateAction::class)->table($expense)) + ->assertHasNoFormErrors(); + + $this->assertDatabaseCount('expenses', 2); + $replica = Expense::where('id', '!=', $expense->id)->sole(); + $this->assertSame(0, $replica->documents()->count()); + } + + protected function setUp(): void + { + parent::setUp(); + + Storage::fake(); + } } diff --git a/tests/Feature/ImportInvoiceDocumentsTest.php b/tests/Feature/ImportInvoiceDocumentsTest.php new file mode 100644 index 0000000..6f3ea45 --- /dev/null +++ b/tests/Feature/ImportInvoiceDocumentsTest.php @@ -0,0 +1,178 @@ +create(['invoiced_at' => '2026-03-11']); + $this->putFixture($this->filename($invoice, '2026-03-11')); + + $this->artisan('documents:import-invoices', ['directory' => $this->directory]) + ->assertExitCode(0); + + $document = $invoice->documents()->sole(); + $this->assertSame($this->filename($invoice, '2026-03-11'), $document->filename); + $this->assertSame('application/pdf', $document->mime_type); + Storage::assertExists($document->path); + } + + #[Test] + public function it_reports_a_file_with_no_matching_invoice_as_unmatched(): void + { + $this->putFixture('202603119999_rechnung_acmeug.pdf'); + + $this->artisan('documents:import-invoices', ['directory' => $this->directory]) + ->expectsOutputToContain('no invoice with id 9999') + ->assertExitCode(0); + + $this->assertSame(0, Document::count()); + } + + #[Test] + public function it_reports_an_unparseable_filename_as_unrecognized(): void + { + $this->putFixture('not-a-valid-invoice-filename.pdf'); + + $this->artisan('documents:import-invoices', ['directory' => $this->directory]) + ->expectsOutputToContain('unrecognized filename') + ->assertExitCode(0); + + $this->assertSame(0, Document::count()); + } + + #[Test] + public function it_skips_an_invoice_that_already_has_a_document_unless_forced(): void + { + $invoice = Invoice::factory()->create(['invoiced_at' => '2026-03-11']); + $existing = Document::factory()->for($invoice, 'documentable')->create(); + $this->putFixture($this->filename($invoice, '2026-03-11')); + + $this->artisan('documents:import-invoices', ['directory' => $this->directory]) + ->assertExitCode(0); + + $this->assertSame(1, $invoice->documents()->count()); + $this->assertTrue($invoice->documents()->sole()->is($existing)); + + $this->artisan('documents:import-invoices', ['directory' => $this->directory, '--force' => true]) + ->assertExitCode(0); + + $this->assertModelMissing($existing); + $this->assertSame(1, $invoice->documents()->count()); + $this->assertSame($this->filename($invoice, '2026-03-11'), $invoice->documents()->sole()->filename); + } + + #[Test] + public function it_treats_two_plain_files_for_the_same_invoice_as_a_conflict(): void + { + $invoice = Invoice::factory()->create(['invoiced_at' => '2026-03-11']); + $this->putFixture($this->filename($invoice, '2026-03-11')); + $this->putFixture($this->filename($invoice, '2026-03-12')); + + $this->artisan('documents:import-invoices', ['directory' => $this->directory]) + ->expectsOutputToContain('conflict') + ->assertExitCode(0); + + $this->assertSame(0, $invoice->documents()->count()); + } + + #[Test] + public function it_leaves_storage_and_database_untouched_on_a_dry_run(): void + { + $invoice = Invoice::factory()->create(['invoiced_at' => '2026-03-11']); + $this->putFixture($this->filename($invoice, '2026-03-11')); + + $this->artisan('documents:import-invoices', ['directory' => $this->directory, '--dry-run' => true]) + ->expectsOutputToContain('dry run, nothing written') + ->assertExitCode(0); + + $this->assertSame(0, $invoice->documents()->count()); + $this->assertSame(0, Document::count()); + } + + #[Test] + public function it_ignores_non_pdf_files(): void + { + $invoice = Invoice::factory()->create(['invoiced_at' => '2026-03-11']); + file_put_contents("{$this->directory}/" . str_replace('.pdf', '.txt', $this->filename($invoice, '2026-03-11')), 'not a pdf'); + + $this->artisan('documents:import-invoices', ['directory' => $this->directory]) + ->assertExitCode(0); + + $this->assertSame(0, Document::count()); + } + + #[Test] + public function it_imports_a_plain_and_version_2_pair_with_version_2_as_latest(): void + { + $invoice = Invoice::factory()->create(['invoiced_at' => '2026-03-11']); + $plainName = $this->filename($invoice, '2026-03-11'); + $version2Name = str_replace('_rechnung', '-2_rechnung', $plainName); + $this->putFixture($plainName); + $this->putFixture($version2Name); + + $this->artisan('documents:import-invoices', ['directory' => $this->directory]) + ->assertExitCode(0); + + $this->assertSame(2, $invoice->documents()->count()); + $latest = $invoice->documents()->latest()->first(); + $this->assertSame($version2Name, $latest->filename); + } + + #[Test] + public function it_imports_a_lone_version_2_file_as_the_only_document(): void + { + $invoice = Invoice::factory()->create(['invoiced_at' => '2026-03-11']); + $version2Name = str_replace('_rechnung', '-2_rechnung', $this->filename($invoice, '2026-03-11')); + $this->putFixture($version2Name); + + $this->artisan('documents:import-invoices', ['directory' => $this->directory]) + ->assertExitCode(0); + + $document = $invoice->documents()->sole(); + $this->assertSame($version2Name, $document->filename); + } + + protected function setUp(): void + { + parent::setUp(); + + Storage::fake(); + + $this->directory = sys_get_temp_dir() . '/invoice-import-test-' . uniqid(); + mkdir($this->directory); + } + + protected function tearDown(): void + { + foreach (glob("{$this->directory}/*") as $file) { + unlink($file); + } + rmdir($this->directory); + + parent::tearDown(); + } + + private function filename(Invoice $invoice, string $date): string + { + return str_replace('-', '', $date) . str_pad((string) $invoice->id, 4, '0', STR_PAD_LEFT) . '_rechnung_acmeug.pdf'; + } + + private function putFixture(string $filename): void + { + file_put_contents("{$this->directory}/{$filename}", '%PDF-1.4 fixture content'); + } +} diff --git a/tests/Feature/ImportQuoteDocumentsTest.php b/tests/Feature/ImportQuoteDocumentsTest.php new file mode 100644 index 0000000..8b5746d --- /dev/null +++ b/tests/Feature/ImportQuoteDocumentsTest.php @@ -0,0 +1,165 @@ +create(); + $this->putFixture($this->filename($project)); + + $this->artisan('documents:import-quotes', ['directory' => $this->directory]) + ->assertExitCode(0); + + $document = $project->documents()->sole(); + $this->assertSame($this->filename($project), $document->filename); + $this->assertSame('application/pdf', $document->mime_type); + Storage::assertExists($document->path); + } + + #[Test] + public function it_reports_a_file_with_no_matching_project_as_unmatched(): void + { + $this->putFixture('202603119999_altes-angebot.pdf'); + + $this->artisan('documents:import-quotes', ['directory' => $this->directory]) + ->expectsOutputToContain('no project with id 9999') + ->assertExitCode(0); + + $this->assertSame(0, Document::count()); + } + + #[Test] + public function it_reports_an_unparseable_filename_as_unrecognized(): void + { + $this->putFixture('not-a-valid-quote-filename.pdf'); + + $this->artisan('documents:import-quotes', ['directory' => $this->directory]) + ->expectsOutputToContain('unrecognized filename') + ->assertExitCode(0); + + $this->assertSame(0, Document::count()); + } + + #[Test] + public function it_skips_a_project_that_already_has_a_document_unless_forced(): void + { + $project = Project::factory()->create(); + $existing = Document::factory()->for($project, 'documentable')->create(); + $this->putFixture($this->filename($project)); + + $this->artisan('documents:import-quotes', ['directory' => $this->directory]) + ->assertExitCode(0); + + $this->assertSame(1, $project->documents()->count()); + $this->assertTrue($project->documents()->sole()->is($existing)); + + $this->artisan('documents:import-quotes', ['directory' => $this->directory, '--force' => true]) + ->assertExitCode(0); + + $this->assertModelMissing($existing); + $this->assertSame($this->filename($project), $project->documents()->sole()->filename); + } + + #[Test] + public function it_treats_two_plain_files_for_the_same_project_as_a_conflict(): void + { + $project = Project::factory()->create(); + $this->putFixture($this->filename($project, '2026-03-11')); + $this->putFixture($this->filename($project, '2026-03-12')); + + $this->artisan('documents:import-quotes', ['directory' => $this->directory]) + ->expectsOutputToContain('conflict') + ->assertExitCode(0); + + $this->assertSame(0, $project->documents()->count()); + } + + #[Test] + public function it_leaves_storage_and_database_untouched_on_a_dry_run(): void + { + $project = Project::factory()->create(); + $this->putFixture($this->filename($project)); + + $this->artisan('documents:import-quotes', ['directory' => $this->directory, '--dry-run' => true]) + ->expectsOutputToContain('dry run, nothing written') + ->assertExitCode(0); + + $this->assertSame(0, $project->documents()->count()); + $this->assertSame(0, Document::count()); + } + + #[Test] + public function it_imports_a_plain_and_version_2_pair_with_version_2_as_latest(): void + { + $project = Project::factory()->create(); + $plainName = $this->filename($project); + $version2Name = str_replace('_angebot', '-2_angebot', $plainName); + $this->putFixture($plainName); + $this->putFixture($version2Name); + + $this->artisan('documents:import-quotes', ['directory' => $this->directory]) + ->assertExitCode(0); + + $this->assertSame(2, $project->documents()->count()); + $latest = $project->documents()->latest()->first(); + $this->assertSame($version2Name, $latest->filename); + } + + #[Test] + public function it_imports_a_lone_version_2_file_as_the_only_document(): void + { + $project = Project::factory()->create(); + $version2Name = str_replace('_angebot', '-2_angebot', $this->filename($project)); + $this->putFixture($version2Name); + + $this->artisan('documents:import-quotes', ['directory' => $this->directory]) + ->assertExitCode(0); + + $document = $project->documents()->sole(); + $this->assertSame($version2Name, $document->filename); + } + + protected function setUp(): void + { + parent::setUp(); + + Storage::fake(); + + $this->directory = sys_get_temp_dir() . '/quote-import-test-' . uniqid(); + mkdir($this->directory); + } + + protected function tearDown(): void + { + foreach (glob("{$this->directory}/*") as $file) { + unlink($file); + } + rmdir($this->directory); + + parent::tearDown(); + } + + private function filename(Project $project, string $date = '2026-03-11'): string + { + return str_replace('-', '', $date) . str_pad((string) $project->id, 4, '0', STR_PAD_LEFT) . '_angebot.pdf'; + } + + private function putFixture(string $filename): void + { + file_put_contents("{$this->directory}/{$filename}", '%PDF-1.4 fixture content'); + } +} diff --git a/tests/Feature/InvoiceResourceTest.php b/tests/Feature/InvoiceResourceTest.php index 1df19e8..7f99dbd 100644 --- a/tests/Feature/InvoiceResourceTest.php +++ b/tests/Feature/InvoiceResourceTest.php @@ -6,13 +6,16 @@ use App\Filament\Resources\InvoiceResource; use App\Filament\Resources\InvoiceResource\Pages\EditInvoice; use App\Filament\Resources\InvoiceResource\Pages\ListInvoices; +use App\Models\Document; use App\Models\Invoice; use App\Models\Project; +use App\Models\Setting; use App\Models\User; use Filament\Actions\CreateAction; use Filament\Actions\DeleteAction; use Filament\Actions\Testing\TestAction; use Illuminate\Foundation\Testing\RefreshDatabase; +use Illuminate\Support\Facades\Storage; use Livewire\Livewire; use PHPUnit\Framework\Attributes\Test; use Tests\TestCase; @@ -21,6 +24,9 @@ class InvoiceResourceTest extends TestCase { use RefreshDatabase; + private string $logoPath; + private string $signaturePath; + #[Test] public function it_redirects_guests_away_from_the_invoice_list(): void { @@ -144,4 +150,116 @@ public function it_deletes_an_invoice_from_the_table(): void $this->assertModelMissing($invoice); } + + #[Test] + public function it_disables_pdf_and_xml_downloads_on_the_edit_page_until_matching_documents_exist(): void + { + $this->actingAs(User::factory()->create()); + $invoice = Invoice::factory()->create(); + + Livewire::test(EditInvoice::class, ['record' => $invoice->getKey()]) + ->assertActionDisabled('pdf') + ->assertActionDisabled('xml'); + + Document::factory()->for($invoice, 'documentable')->create(['filename' => 'invoice.pdf', 'attachment_path' => null]); + + Livewire::test(EditInvoice::class, ['record' => $invoice->getKey()]) + ->assertActionEnabled('pdf') + ->assertActionDisabled('xml'); + + Document::factory()->for($invoice, 'documentable')->create([ + 'filename' => 'invoice.pdf', + 'attachment_path' => 'documents/invoice.xml', + 'attachment_filename' => 'invoice.xml', + ]); + + Livewire::test(EditInvoice::class, ['record' => $invoice->getKey()]) + ->assertActionEnabled('pdf') + ->assertActionEnabled('xml'); + } + + #[Test] + public function it_hides_pdf_and_xml_downloads_in_the_table_until_matching_documents_exist(): void + { + $this->actingAs(User::factory()->create()); + $invoice = Invoice::factory()->create(); + + Livewire::test(ListInvoices::class, ['activeTab' => 'all']) + ->assertTableActionHidden('pdf', $invoice) + ->assertTableActionHidden('xml', $invoice); + + Document::factory()->for($invoice, 'documentable')->create([ + 'filename' => 'invoice.pdf', + 'attachment_path' => 'documents/invoice.xml', + 'attachment_filename' => 'invoice.xml', + ]); + + Livewire::test(ListInvoices::class, ['activeTab' => 'all']) + ->assertTableActionVisible('pdf', $invoice) + ->assertTableActionVisible('xml', $invoice); + } + + #[Test] + public function it_generates_and_attaches_a_single_invoice_document_from_the_edit_page(): void + { + $this->actingAs(User::factory()->create()); + $invoice = Invoice::factory()->create(); + + Livewire::test(EditInvoice::class, ['record' => $invoice->getKey()]) + ->callAction('generate'); + + $this->assertSame(1, $invoice->documents()->count()); + $this->assertDatabaseHas('documents', [ + 'documentable_type' => Invoice::class, + 'documentable_id' => $invoice->id, + 'mime_type' => 'application/pdf', + 'attachment_mime_type' => 'application/xml', + ]); + } + + protected function setUp(): void + { + parent::setUp(); + + Storage::fake(); + + $this->logoPath = tempnam(sys_get_temp_dir(), 'logo') . '.jpg'; + imagejpeg(imagecreatetruecolor(10, 10), $this->logoPath); + + $this->signaturePath = tempnam(sys_get_temp_dir(), 'signature') . '.png'; + imagepng(imagecreatetruecolor(10, 10), $this->signaturePath); + + $values = [ + 'accountHolder' => 'Account Holder', + 'bank' => 'Test Bank', + 'bic' => 'TESTBIC1', + 'city' => 'Berlin', + 'company' => 'Acme UG', + 'country' => 'Germany', + 'email' => 'contact@acme.test', + 'iban' => 'DE00000000000000000000', + 'logo' => $this->logoPath, + 'name' => 'Acme UG', + 'phone' => '+49123456789', + 'signature' => $this->signaturePath, + 'street' => 'Main Street 1', + 'taxOffice' => 'Finanzamt Berlin', + 'vatId' => 'DE123456789', + 'vatRate' => '0.19', + 'website' => 'https://acme.test', + 'zip' => '12345', + ]; + + foreach ($values as $field => $value) { + Setting::where('field', $field)->update(['value' => $value]); + } + } + + protected function tearDown(): void + { + @unlink($this->logoPath); + @unlink($this->signaturePath); + + parent::tearDown(); + } } diff --git a/tests/Feature/InvoiceServiceTest.php b/tests/Feature/InvoiceServiceTest.php index d9839cf..89ea1fd 100644 --- a/tests/Feature/InvoiceServiceTest.php +++ b/tests/Feature/InvoiceServiceTest.php @@ -4,6 +4,7 @@ use App\Enums\LanguageCode; use App\Models\Client; +use App\Models\Document; use App\Models\Invoice; use App\Models\Position; use App\Models\Project; @@ -123,6 +124,45 @@ public function it_generates_an_en16931_xml_invoice_with_the_expected_content(): $this->assertStringContainsString('' . $invoice->gross . '', $xml); } + #[Test] + public function it_generates_a_single_document_holding_the_pdf_and_its_xml_attachment(): void + { + $invoice = $this->makeInvoice(); + Position::factory()->for($invoice)->create(['pause_duration' => 0]); + + InvoiceService::generateDocuments($invoice); + + $this->assertSame(1, $invoice->documents()->count()); + + $document = $invoice->documents()->sole(); + + $this->assertSame($this->expectedPdfFilename($invoice), $document->filename); + $this->assertSame('application/pdf', $document->mime_type); + $this->assertSame('local', $document->disk); + $this->assertGreaterThan(0, $document->size); + Storage::assertExists($document->path); + $this->assertStringStartsWith('%PDF-', Storage::get($document->path)); + + $this->assertSame($this->expectedXmlFilename($invoice), $document->attachment_filename); + $this->assertSame('application/xml', $document->attachment_mime_type); + $this->assertGreaterThan(0, $document->attachment_size); + Storage::assertExists($document->attachment_path); + } + + #[Test] + public function it_keeps_previous_documents_when_generating_again(): void + { + $invoice = $this->makeInvoice(); + Position::factory()->for($invoice)->create(['pause_duration' => 0]); + + InvoiceService::generateDocuments($invoice); + InvoiceService::generateDocuments($invoice); + + $this->assertSame(2, $invoice->documents()->count()); + $this->assertCount(2, Document::all()->pluck('path')->unique()); + $this->assertCount(2, Document::all()->pluck('attachment_path')->unique()); + } + protected function setUp(): void { parent::setUp(); diff --git a/tests/Feature/MinorAssetsListTest.php b/tests/Feature/MinorAssetsListTest.php index a0b6e37..d5ded65 100644 --- a/tests/Feature/MinorAssetsListTest.php +++ b/tests/Feature/MinorAssetsListTest.php @@ -4,6 +4,7 @@ use App\Enums\ExpenseCategory; use App\Filament\Widgets\MinorAssetsList; +use App\Models\Document; use App\Models\Expense; use App\Models\User; use Illuminate\Foundation\Testing\RefreshDatabase; @@ -125,4 +126,26 @@ public function it_only_lists_minor_assets_expenses_from_the_selected_year(): vo $this->assertCount(1, $records); $this->assertTrue($records->contains($inYear)); } + + #[Test] + public function it_hides_the_bill_download_until_a_document_exists(): void + { + $this->actingAs(User::factory()->create()); + $year = now()->year - 1; + $expense = Expense::factory()->create([ + 'expended_at' => "$year-06-01", + 'category' => ExpenseCategory::MinorAssets, + 'price' => 500, + 'quantity' => 1, + 'taxable' => false, + ]); + + Livewire::test(MinorAssetsList::class) + ->assertTableActionHidden('bill', $expense); + + Document::factory()->for($expense, 'documentable')->create(); + + Livewire::test(MinorAssetsList::class) + ->assertTableActionVisible('bill', $expense); + } } diff --git a/tests/Feature/ProjectResourceTest.php b/tests/Feature/ProjectResourceTest.php index 444ccfd..8868005 100644 --- a/tests/Feature/ProjectResourceTest.php +++ b/tests/Feature/ProjectResourceTest.php @@ -7,12 +7,15 @@ use App\Filament\Resources\ProjectResource\Pages\EditProject; use App\Filament\Resources\ProjectResource\Pages\ListProjects; use App\Models\Client; +use App\Models\Document; use App\Models\Project; +use App\Models\Setting; use App\Models\User; use Filament\Actions\CreateAction; use Filament\Actions\DeleteAction; use Filament\Actions\Testing\TestAction; use Illuminate\Foundation\Testing\RefreshDatabase; +use Illuminate\Support\Facades\Storage; use Livewire\Livewire; use PHPUnit\Framework\Attributes\Test; use Tests\TestCase; @@ -21,6 +24,9 @@ class ProjectResourceTest extends TestCase { use RefreshDatabase; + private string $logoPath; + private string $signaturePath; + #[Test] public function it_redirects_guests_away_from_the_project_list(): void { @@ -148,4 +154,97 @@ public function it_deletes_a_project_from_the_table(): void $this->assertModelMissing($project); } + + #[Test] + public function it_hides_the_pdf_download_in_the_table_until_a_quote_document_exists(): void + { + $this->actingAs(User::factory()->create()); + $project = Project::factory()->create(); + + Livewire::test(ListProjects::class, ['activeTab' => 'all']) + ->assertTableActionHidden('pdf', $project); + + Document::factory()->for($project, 'documentable')->create(['filename' => 'quote.pdf']); + + Livewire::test(ListProjects::class, ['activeTab' => 'all']) + ->assertTableActionVisible('pdf', $project); + } + + #[Test] + public function it_disables_the_pdf_download_on_the_edit_page_until_a_quote_document_exists(): void + { + $this->actingAs(User::factory()->create()); + $project = Project::factory()->create(); + + Livewire::test(EditProject::class, ['record' => $project->getKey()]) + ->assertActionDisabled('pdf'); + + Document::factory()->for($project, 'documentable')->create(['filename' => 'quote.pdf']); + + Livewire::test(EditProject::class, ['record' => $project->getKey()]) + ->assertActionEnabled('pdf'); + } + + #[Test] + public function it_generates_and_attaches_a_quote_document_from_the_edit_page(): void + { + $this->actingAs(User::factory()->create()); + $project = Project::factory()->create(); + + Livewire::test(EditProject::class, ['record' => $project->getKey()]) + ->callAction('generate'); + + $this->assertSame(1, $project->documents()->count()); + $this->assertDatabaseHas('documents', [ + 'documentable_type' => Project::class, + 'documentable_id' => $project->id, + 'mime_type' => 'application/pdf', + ]); + } + + protected function setUp(): void + { + parent::setUp(); + + Storage::fake(); + + $this->logoPath = tempnam(sys_get_temp_dir(), 'logo') . '.jpg'; + imagejpeg(imagecreatetruecolor(10, 10), $this->logoPath); + + $this->signaturePath = tempnam(sys_get_temp_dir(), 'signature') . '.png'; + imagepng(imagecreatetruecolor(10, 10), $this->signaturePath); + + $values = [ + 'accountHolder' => 'Account Holder', + 'bank' => 'Test Bank', + 'bic' => 'TESTBIC1', + 'city' => 'Berlin', + 'company' => 'Acme UG', + 'country' => 'Germany', + 'email' => 'contact@acme.test', + 'iban' => 'DE00000000000000000000', + 'logo' => $this->logoPath, + 'name' => 'Acme UG', + 'phone' => '+49123456789', + 'signature' => $this->signaturePath, + 'street' => 'Main Street 1', + 'taxOffice' => 'Finanzamt Berlin', + 'vatId' => 'DE123456789', + 'vatRate' => '0.19', + 'website' => 'https://acme.test', + 'zip' => '12345', + ]; + + foreach ($values as $field => $value) { + Setting::where('field', $field)->update(['value' => $value]); + } + } + + protected function tearDown(): void + { + @unlink($this->logoPath); + @unlink($this->signaturePath); + + parent::tearDown(); + } } diff --git a/tests/Feature/ProjectServiceTest.php b/tests/Feature/ProjectServiceTest.php index 5ea5222..bbdb1d7 100644 --- a/tests/Feature/ProjectServiceTest.php +++ b/tests/Feature/ProjectServiceTest.php @@ -4,6 +4,7 @@ use App\Enums\LanguageCode; use App\Models\Client; +use App\Models\Document; use App\Models\Estimate; use App\Models\Project; use App\Models\Setting; @@ -78,6 +79,42 @@ public function it_generates_a_quote_pdf_with_multiple_estimates(): void $this->assertStringStartsWith('%PDF-', Storage::get($filename)); } + #[Test] + public function it_generates_and_attaches_a_quote_document_to_the_project(): void + { + $client = Client::factory()->create(['language' => LanguageCode::DE]); + $project = Project::factory()->for($client)->hourly()->create(); + Estimate::factory()->for($project)->create(); + + ProjectService::generateDocument($project); + + $this->assertSame(1, $project->documents()->count()); + + $document = $project->documents()->sole(); + + $this->assertSame($this->expectedFilename(), $document->filename); + $this->assertSame('application/pdf', $document->mime_type); + $this->assertSame('local', $document->disk); + $this->assertGreaterThan(0, $document->size); + $this->assertNull($document->attachment_path); + Storage::assertExists($document->path); + $this->assertStringStartsWith('%PDF-', Storage::get($document->path)); + } + + #[Test] + public function it_keeps_previous_quote_documents_when_generating_again(): void + { + $client = Client::factory()->create(['language' => LanguageCode::DE]); + $project = Project::factory()->for($client)->hourly()->create(); + Estimate::factory()->for($project)->create(); + + ProjectService::generateDocument($project); + ProjectService::generateDocument($project); + + $this->assertSame(2, $project->documents()->count()); + $this->assertCount(2, Document::all()->pluck('path')->unique()); + } + protected function setUp(): void { parent::setUp(); diff --git a/tests/Unit/Models/DocumentTest.php b/tests/Unit/Models/DocumentTest.php new file mode 100644 index 0000000..8f9e95c --- /dev/null +++ b/tests/Unit/Models/DocumentTest.php @@ -0,0 +1,71 @@ +getFillable())->toBe([ + 'disk', + 'path', + 'filename', + 'mime_type', + 'size', + 'attachment_path', + 'attachment_filename', + 'attachment_mime_type', + 'attachment_size', + ]); +}); + +it('casts attributes to their expected types', function () { + $document = Document::factory()->create(['size' => '1234', 'attachment_size' => '5678']); + + expect($document->size)->toBeInt() + ->and($document->size)->toBe(1234) + ->and($document->attachment_size)->toBeInt() + ->and($document->attachment_size)->toBe(5678); +}); + +it('derives the extension from the filename', function () { + $document = Document::factory()->create(['filename' => 'invoice.PDF']); + + expect($document->extension)->toBe('pdf'); +}); + +it('belongs to its documentable model', function () { + $invoice = Invoice::factory()->create(); + $document = Document::factory()->for($invoice, 'documentable')->create(); + + expect($document->documentable)->toBeInstanceOf(Invoice::class) + ->and($document->documentable->id)->toBe($invoice->id); +}); + +it('deletes the underlying file when the record is deleted', function () { + Storage::fake('local'); + $document = Document::factory()->create([ + 'disk' => 'local', + 'path' => 'documents/example.pdf', + ]); + Storage::disk('local')->put($document->path, 'content'); + + $document->delete(); + + Storage::disk('local')->assertMissing($document->path); +}); + +it('also deletes the attachment file when the record is deleted', function () { + Storage::fake('local'); + $document = Document::factory()->create([ + 'disk' => 'local', + 'path' => 'documents/example.pdf', + 'attachment_path' => 'documents/example.xml', + ]); + Storage::disk('local')->put($document->path, 'content'); + Storage::disk('local')->put($document->attachment_path, 'content'); + + $document->delete(); + + Storage::disk('local')->assertMissing($document->path); + Storage::disk('local')->assertMissing($document->attachment_path); +}); diff --git a/tests/Unit/Models/ExpenseTest.php b/tests/Unit/Models/ExpenseTest.php index 943c515..4d29d53 100644 --- a/tests/Unit/Models/ExpenseTest.php +++ b/tests/Unit/Models/ExpenseTest.php @@ -2,6 +2,7 @@ use App\Enums\ExpenseCategory; use App\Enums\TimeUnit; +use App\Models\Document; use App\Models\Expense; use Carbon\Carbon; @@ -18,6 +19,13 @@ ]); }); +it('has many documents', function () { + $expense = Expense::factory()->create(); + Document::factory()->count(2)->for($expense, 'documentable')->create(); + + expect($expense->documents)->toHaveCount(2); +}); + it('derives the year from the expended_at date', function () { $expense = Expense::factory()->create(['expended_at' => '2026-03-15']); diff --git a/tests/Unit/Models/InvoiceTest.php b/tests/Unit/Models/InvoiceTest.php index c040191..45f2d14 100644 --- a/tests/Unit/Models/InvoiceTest.php +++ b/tests/Unit/Models/InvoiceTest.php @@ -3,6 +3,7 @@ use App\Enums\InvoiceStatus; use App\Enums\PricingUnit; use App\Enums\TimeUnit; +use App\Models\Document; use App\Models\Invoice; use App\Models\Position; use App\Models\Project; @@ -49,6 +50,13 @@ ->and($invoice->positions)->toHaveCount(2); }); +it('has many documents', function () { + $invoice = Invoice::factory()->create(); + Document::factory()->count(2)->for($invoice, 'documentable')->create(); + + expect($invoice->documents)->toHaveCount(2); +}); + it('scopes invoices by their active, waiting and finished state', function () { $active = Invoice::factory()->active()->create(); $waiting = Invoice::factory()->waiting()->create(); diff --git a/tests/Unit/Models/ProjectTest.php b/tests/Unit/Models/ProjectTest.php index 09ee5f2..74b4511 100644 --- a/tests/Unit/Models/ProjectTest.php +++ b/tests/Unit/Models/ProjectTest.php @@ -2,6 +2,7 @@ use App\Enums\PricingUnit; use App\Models\Client; +use App\Models\Document; use App\Models\Estimate; use App\Models\Invoice; use App\Models\Position; @@ -52,6 +53,13 @@ ->and($project->invoices)->toHaveCount(3); }); +it('has many documents', function () { + $project = Project::factory()->create(); + Document::factory()->count(2)->for($project, 'documentable')->create(); + + expect($project->documents)->toHaveCount(2); +}); + it('scopes projects by their active, upcoming, finished and aborted state', function () { $active = Project::factory()->active()->create(); $upcoming = Project::factory()->upcoming()->create();