From ed8b8af036307ffdd10d36859841e8dc7807046d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andreas=20M=C3=BCller?= Date: Fri, 11 Sep 2026 13:19:00 +0200 Subject: [PATCH 1/6] =?UTF-8?q?=E2=9E=95=20Add=20document=20model=20and=20?= =?UTF-8?q?documentables=20structure?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/Models/Document.php | 54 +++++++++++++++++++ app/Models/Expense.php | 9 ++++ app/Models/Invoice.php | 9 ++++ app/Models/Project.php | 9 ++++ database/factories/DocumentFactory.php | 40 ++++++++++++++ ...26_09_11_000001_create_documents_table.php | 32 +++++++++++ tests/Unit/Models/DocumentTest.php | 49 +++++++++++++++++ tests/Unit/Models/ExpenseTest.php | 8 +++ tests/Unit/Models/InvoiceTest.php | 8 +++ tests/Unit/Models/ProjectTest.php | 8 +++ 10 files changed, 226 insertions(+) create mode 100644 app/Models/Document.php create mode 100644 database/factories/DocumentFactory.php create mode 100644 database/migrations/2026_09_11_000001_create_documents_table.php create mode 100644 tests/Unit/Models/DocumentTest.php diff --git a/app/Models/Document.php b/app/Models/Document.php new file mode 100644 index 0000000..f0d558b --- /dev/null +++ b/app/Models/Document.php @@ -0,0 +1,54 @@ +morphTo(); + } + + protected static function booted(): void + { + static::deleting(function (self $document) { + Storage::disk($document->disk)->delete($document->path); + }); + } + + protected function casts(): array + { + return [ + '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/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..5ea379e --- /dev/null +++ b/database/migrations/2026_09_11_000001_create_documents_table.php @@ -0,0 +1,32 @@ +id(); + $table->morphs('documentable'); + $table->string('disk'); + $table->string('path'); + $table->string('filename'); + $table->string('mime_type'); + $table->unsignedInteger('size'); + $table->timestamps(); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('documents'); + } +}; diff --git a/tests/Unit/Models/DocumentTest.php b/tests/Unit/Models/DocumentTest.php new file mode 100644 index 0000000..1ab6222 --- /dev/null +++ b/tests/Unit/Models/DocumentTest.php @@ -0,0 +1,49 @@ +getFillable())->toBe([ + 'disk', + 'path', + 'filename', + 'mime_type', + 'size', + ]); +}); + +it('casts attributes to their expected types', function () { + $document = Document::factory()->create(['size' => '1234']); + + expect($document->size)->toBeInt() + ->and($document->size)->toBe(1234); +}); + +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); +}); 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(); From 268e53b72a6e172bfe8ea4e4185f96d6162e7932 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andreas=20M=C3=BCller?= Date: Fri, 11 Sep 2026 14:08:19 +0200 Subject: [PATCH 2/6] =?UTF-8?q?=E2=9E=95=20Implement=20invoice=20documents?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Relations/DocumentsRelationManager.php | 58 +++++++++ app/Filament/Resources/InvoiceResource.php | 21 +++- .../InvoiceResource/Pages/EditInvoice.php | 20 ++- app/Models/Document.php | 14 ++- app/Services/InvoiceService.php | 54 ++++++++ ...26_09_11_000001_create_documents_table.php | 4 + lang/de.json | 5 + lang/en.json | 5 + tests/Feature/InvoiceResourceTest.php | 118 ++++++++++++++++++ tests/Feature/InvoiceServiceTest.php | 40 ++++++ tests/Unit/Models/DocumentTest.php | 26 +++- 11 files changed, 348 insertions(+), 17 deletions(-) create mode 100644 app/Filament/Relations/DocumentsRelationManager.php 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/InvoiceResource.php b/app/Filament/Resources/InvoiceResource.php index 70e5bf8..cbeda0b 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; @@ -158,21 +159,28 @@ public static function table(Table $table): Table ->schema(self::formFields(6, false)) ->slideOver() ->modalWidth(Width::ExtraLarge), + 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') + ->hidden(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') + ->hidden(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); }), Action::make('send') ->label(__('send')) @@ -238,6 +246,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..09b9e02 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') + ->hidden(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') + ->hidden(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/Models/Document.php b/app/Models/Document.php index f0d558b..de550ac 100644 --- a/app/Models/Document.php +++ b/app/Models/Document.php @@ -18,6 +18,10 @@ class Document extends Model 'filename', 'mime_type', 'size', + 'attachment_path', + 'attachment_filename', + 'attachment_mime_type', + 'attachment_size', ]; /** @@ -32,15 +36,19 @@ 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', - 'created_at' => 'datetime', - 'updated_at' => 'datetime', + 'size' => 'integer', + 'attachment_size' => 'integer', + 'created_at' => 'datetime', + 'updated_at' => 'datetime', ]; } 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/database/migrations/2026_09_11_000001_create_documents_table.php b/database/migrations/2026_09_11_000001_create_documents_table.php index 5ea379e..dc9bf0c 100644 --- a/database/migrations/2026_09_11_000001_create_documents_table.php +++ b/database/migrations/2026_09_11_000001_create_documents_table.php @@ -18,6 +18,10 @@ public function up(): void $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(); }); } diff --git a/lang/de.json b/lang/de.json index 7182d73..6d01d09 100644 --- a/lang/de.json +++ b/lang/de.json @@ -47,6 +47,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 +64,7 @@ "expendedAt": "Ausgegeben", "expense": "Ausgabe|Ausgaben", "field": "Feld", + "filename": "Dateiname", "finished": "Beendet", "finishedAt": "Beendet am", "flatRate": "pauschal", @@ -83,6 +85,7 @@ "vr118": "Vorauszahlungssoll", "vr75": "Übrige nicht steuerbare Umsätze (Leistungsort nicht im Inland)" }, + "generateInvoiceDocument": "Rechnungsdokument generieren", "gift": "Schenkung|Schenkungen", "good": "Ware", "gross": "Brutto", @@ -112,6 +115,7 @@ "invoiceDate": "Rechnungsdatum", "invoiceDates": "Rechnungsdaten", "invoiceDateSet": "Rechnungsdatum gesetzt", + "invoiceDocumentGenerated": "Rechnungsdokument erstellt", "invoiceIssued": "Rechnung gestellt", "invoiceNumber": "Rechnungsnummer", "invoicePaid": "Rechnung beglichen", @@ -190,6 +194,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..bb60775 100644 --- a/lang/en.json +++ b/lang/en.json @@ -47,6 +47,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 +64,7 @@ "expendedAt": "Expended at", "expense": "Expense|Expenses", "field": "Field", + "filename": "Filename", "finished": "Finished", "finishedAt": "Finished at", "flatRate": "flat-rate", @@ -83,6 +85,7 @@ "vr118": "Prepayment target", "vr75": "Other non-taxable transactions (place of performance not in the country)" }, + "generateInvoiceDocument": "Generate invoice document", "gift": "Gift|Gifts", "good": "Good", "gross": "Gross", @@ -112,6 +115,7 @@ "invoiceDate": "Invoice Date", "invoiceDates": "Invoice dates", "invoiceDateSet": "Invoice date set", + "invoiceDocumentGenerated": "Invoice document generated", "invoiceIssued": "Invoice issued", "invoiceNumber": "Invoice Number", "invoicePaid": "Invoice paid", @@ -190,6 +194,7 @@ "short": "Short", "sick": "Sick", "signature": "Signature", + "size": "Size", "startAt": "Start at", "startedAt": "Started at", "statementOfWork": "Statement of Work", diff --git a/tests/Feature/InvoiceResourceTest.php b/tests/Feature/InvoiceResourceTest.php index 1df19e8..d921e93 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_hides_pdf_and_xml_downloads_from_the_edit_page_until_matching_documents_exist(): void + { + $this->actingAs(User::factory()->create()); + $invoice = Invoice::factory()->create(); + + Livewire::test(EditInvoice::class, ['record' => $invoice->getKey()]) + ->assertActionHidden('pdf') + ->assertActionHidden('xml'); + + Document::factory()->for($invoice, 'documentable')->create(['filename' => 'invoice.pdf', 'attachment_path' => null]); + + Livewire::test(EditInvoice::class, ['record' => $invoice->getKey()]) + ->assertActionVisible('pdf') + ->assertActionHidden('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()]) + ->assertActionVisible('pdf') + ->assertActionVisible('xml'); + } + + #[Test] + public function it_hides_pdf_and_xml_downloads_from_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/Unit/Models/DocumentTest.php b/tests/Unit/Models/DocumentTest.php index 1ab6222..8f9e95c 100644 --- a/tests/Unit/Models/DocumentTest.php +++ b/tests/Unit/Models/DocumentTest.php @@ -11,14 +11,20 @@ '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']); + $document = Document::factory()->create(['size' => '1234', 'attachment_size' => '5678']); expect($document->size)->toBeInt() - ->and($document->size)->toBe(1234); + ->and($document->size)->toBe(1234) + ->and($document->attachment_size)->toBeInt() + ->and($document->attachment_size)->toBe(5678); }); it('derives the extension from the filename', function () { @@ -47,3 +53,19 @@ 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); +}); From a80ba216bd53112fc12eab478c3b69f2bff9d31e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andreas=20M=C3=BCller?= Date: Fri, 11 Sep 2026 14:21:20 +0200 Subject: [PATCH 3/6] =?UTF-8?q?=F0=9F=94=A8=20Only=20show=20download=20but?= =?UTF-8?q?tons=20for=20existing=20documents?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/Filament/Resources/InvoiceResource.php | 38 ++++++++++--------- .../InvoiceResource/Pages/EditInvoice.php | 4 +- lang/de.json | 2 +- tests/Feature/InvoiceResourceTest.php | 16 ++++---- 4 files changed, 31 insertions(+), 29 deletions(-) diff --git a/app/Filament/Resources/InvoiceResource.php b/app/Filament/Resources/InvoiceResource.php index cbeda0b..beee4c6 100644 --- a/app/Filament/Resources/InvoiceResource.php +++ b/app/Filament/Resources/InvoiceResource.php @@ -147,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() @@ -166,22 +184,6 @@ public static function table(Table $table): Table InvoiceService::generateDocuments($record); Notification::make()->title(__('invoiceDocumentGenerated'))->success()->send(); }), - Action::make('pdf') - ->label(__('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(__('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); - }), Action::make('send') ->label(__('send')) ->icon('tabler-mail-forward') @@ -227,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'), diff --git a/app/Filament/Resources/InvoiceResource/Pages/EditInvoice.php b/app/Filament/Resources/InvoiceResource/Pages/EditInvoice.php index 09b9e02..01e07ec 100644 --- a/app/Filament/Resources/InvoiceResource/Pages/EditInvoice.php +++ b/app/Filament/Resources/InvoiceResource/Pages/EditInvoice.php @@ -36,7 +36,7 @@ protected function getHeaderActions(): array Action::make('pdf') ->label(__('downloadFiletype', ['type' => 'pdf'])) ->icon('tabler-file-type-pdf') - ->hidden(fn(Invoice $record) => !$record->documents()->exists()) + ->disabled(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); @@ -44,7 +44,7 @@ protected function getHeaderActions(): array Action::make('xml') ->label(__('downloadFiletype', ['type' => 'xml'])) ->icon('tabler-file-type-xml') - ->hidden(fn(Invoice $record) => !$record->documents()->whereNotNull('attachment_path')->exists()) + ->disabled(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); diff --git a/lang/de.json b/lang/de.json index 6d01d09..910a896 100644 --- a/lang/de.json +++ b/lang/de.json @@ -85,7 +85,7 @@ "vr118": "Vorauszahlungssoll", "vr75": "Übrige nicht steuerbare Umsätze (Leistungsort nicht im Inland)" }, - "generateInvoiceDocument": "Rechnungsdokument generieren", + "generateInvoiceDocument": "Rechnung generieren", "gift": "Schenkung|Schenkungen", "good": "Ware", "gross": "Brutto", diff --git a/tests/Feature/InvoiceResourceTest.php b/tests/Feature/InvoiceResourceTest.php index d921e93..7f99dbd 100644 --- a/tests/Feature/InvoiceResourceTest.php +++ b/tests/Feature/InvoiceResourceTest.php @@ -152,20 +152,20 @@ public function it_deletes_an_invoice_from_the_table(): void } #[Test] - public function it_hides_pdf_and_xml_downloads_from_the_edit_page_until_matching_documents_exist(): void + 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()]) - ->assertActionHidden('pdf') - ->assertActionHidden('xml'); + ->assertActionDisabled('pdf') + ->assertActionDisabled('xml'); Document::factory()->for($invoice, 'documentable')->create(['filename' => 'invoice.pdf', 'attachment_path' => null]); Livewire::test(EditInvoice::class, ['record' => $invoice->getKey()]) - ->assertActionVisible('pdf') - ->assertActionHidden('xml'); + ->assertActionEnabled('pdf') + ->assertActionDisabled('xml'); Document::factory()->for($invoice, 'documentable')->create([ 'filename' => 'invoice.pdf', @@ -174,12 +174,12 @@ public function it_hides_pdf_and_xml_downloads_from_the_edit_page_until_matching ]); Livewire::test(EditInvoice::class, ['record' => $invoice->getKey()]) - ->assertActionVisible('pdf') - ->assertActionVisible('xml'); + ->assertActionEnabled('pdf') + ->assertActionEnabled('xml'); } #[Test] - public function it_hides_pdf_and_xml_downloads_from_the_table_until_matching_documents_exist(): void + 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(); From 40707f46fa2652c2341e1cd18c9b49bd0a9af067 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andreas=20M=C3=BCller?= Date: Fri, 11 Sep 2026 14:36:47 +0200 Subject: [PATCH 4/6] =?UTF-8?q?=E2=9E=95=20Implement=20project=20documents?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/Filament/Resources/ProjectResource.php | 27 +++-- .../ProjectResource/Pages/EditProject.php | 18 +++- app/Services/ProjectService.php | 21 ++++ lang/de.json | 2 + lang/en.json | 2 + tests/Feature/ProjectResourceTest.php | 99 +++++++++++++++++++ tests/Feature/ProjectServiceTest.php | 37 +++++++ 7 files changed, 193 insertions(+), 13 deletions(-) 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/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/lang/de.json b/lang/de.json index 910a896..a0b9d4b 100644 --- a/lang/de.json +++ b/lang/de.json @@ -86,6 +86,7 @@ "vr75": "Übrige nicht steuerbare Umsätze (Leistungsort nicht im Inland)" }, "generateInvoiceDocument": "Rechnung generieren", + "generateQuote": "Angebot generieren", "gift": "Schenkung|Schenkungen", "good": "Ware", "gross": "Brutto", @@ -176,6 +177,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.", diff --git a/lang/en.json b/lang/en.json index bb60775..3125723 100644 --- a/lang/en.json +++ b/lang/en.json @@ -86,6 +86,7 @@ "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", @@ -176,6 +177,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.", 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(); From d4dad038df5fc1155917bd22993bf995e0767310 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andreas=20M=C3=BCller?= Date: Fri, 11 Sep 2026 18:10:49 +0200 Subject: [PATCH 5/6] =?UTF-8?q?=E2=9E=95=20Implement=20expense=20documents?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/Filament/Resources/ExpenseResource.php | 92 ++++++++++++- .../ExpenseResource/Pages/ListExpenses.php | 4 +- app/Filament/Widgets/MinorAssetsList.php | 14 ++ lang/de.json | 1 + lang/en.json | 1 + tests/Feature/ExpenseResourceTest.php | 125 ++++++++++++++++++ tests/Feature/MinorAssetsListTest.php | 23 ++++ 7 files changed, 254 insertions(+), 6 deletions(-) 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/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/lang/de.json b/lang/de.json index a0b9d4b..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", diff --git a/lang/en.json b/lang/en.json index 3125723..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", 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/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); + } } From 42f1f45a06a3f5e875bf78806adc07199f947769 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andreas=20M=C3=BCller?= Date: Sun, 13 Sep 2026 00:17:47 +0200 Subject: [PATCH 6/6] =?UTF-8?q?=E2=9E=95=20Add=20existing=20document=20imp?= =?UTF-8?q?ort=20commands?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Commands/ImportInvoiceDocuments.php | 227 ++++++++++++++++++ app/Console/Commands/ImportQuoteDocuments.php | 199 +++++++++++++++ tests/Feature/ImportInvoiceDocumentsTest.php | 178 ++++++++++++++ tests/Feature/ImportQuoteDocumentsTest.php | 165 +++++++++++++ 4 files changed, 769 insertions(+) create mode 100644 app/Console/Commands/ImportInvoiceDocuments.php create mode 100644 app/Console/Commands/ImportQuoteDocuments.php create mode 100644 tests/Feature/ImportInvoiceDocumentsTest.php create mode 100644 tests/Feature/ImportQuoteDocumentsTest.php 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/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'); + } +}