diff --git a/.gitignore b/.gitignore index 375301ed7..bac5879a3 100644 --- a/.gitignore +++ b/.gitignore @@ -80,3 +80,8 @@ package-lock.json *.sqlite /failures.txt /yarnpack.txt +/automation/.idea/ +/automation/vendor/ +/automation/test-honesty/vendor/ +.claude/fable5/runtime/control.json +upd.sh diff --git a/Modules/Core/Database/Seeders/RolesSeeder.php b/Modules/Core/Database/Seeders/RolesSeeder.php index 452f13259..03d996fb3 100644 --- a/Modules/Core/Database/Seeders/RolesSeeder.php +++ b/Modules/Core/Database/Seeders/RolesSeeder.php @@ -120,6 +120,7 @@ function ($p) use ($customerResources) { $isBasicAction = str_starts_with($p, 'view-') || str_starts_with($p, 'create-') || str_starts_with($p, 'edit-') + || str_starts_with($p, 'delete-') || str_starts_with($p, 'export-') || str_starts_with($p, 'duplicate-'); $isCustomerResource = (bool) array_filter( diff --git a/Modules/Core/Support/PDF/Drivers/domPDF.php b/Modules/Core/Support/PDF/Drivers/domPDF.php index 1ab66b9c8..84e18b35b 100644 --- a/Modules/Core/Support/PDF/Drivers/domPDF.php +++ b/Modules/Core/Support/PDF/Drivers/domPDF.php @@ -2,6 +2,8 @@ namespace Modules\Core\Support\PDF\Drivers; +use Dompdf\Dompdf as PDF; +use Dompdf\Options; use Modules\Core\Support\PDF\PDFAbstract; class domPDF extends PDFAbstract diff --git a/Modules/Core/Tests/AbstractAdminPanelTestCase.php b/Modules/Core/Tests/AbstractAdminPanelTestCase.php index 5ea2f7670..a484e8a79 100644 --- a/Modules/Core/Tests/AbstractAdminPanelTestCase.php +++ b/Modules/Core/Tests/AbstractAdminPanelTestCase.php @@ -5,6 +5,9 @@ use Illuminate\Foundation\Testing\RefreshDatabase; use Illuminate\Foundation\Testing\TestCase as BaseTestCase; use Illuminate\Support\Carbon; +use Modules\Core\Database\Seeders\PermissionsSeeder; +use Modules\Core\Database\Seeders\RolesSeeder; +use Modules\Core\Enums\UserRole; use Modules\Core\Models\Company; use Modules\Core\Models\User; @@ -34,6 +37,14 @@ protected function setUp(): void session(['current_company_id' => $this->company->id]); + /* + * Admin resources gate every page on Spatie permissions (canViewAny + * etc.), so the test user needs the seeded super_admin permission set. + */ + (new PermissionsSeeder())->run(); + (new RolesSeeder())->run(); + $this->superAdmin->assignRole(UserRole::SUPER_ADMIN->value); + $this->withoutExceptionHandling(); } diff --git a/Modules/Core/Tests/AbstractCompanyPanelTestCase.php b/Modules/Core/Tests/AbstractCompanyPanelTestCase.php index 1be904606..768d61c53 100644 --- a/Modules/Core/Tests/AbstractCompanyPanelTestCase.php +++ b/Modules/Core/Tests/AbstractCompanyPanelTestCase.php @@ -7,6 +7,9 @@ use Illuminate\Foundation\Testing\RefreshDatabase; use Illuminate\Foundation\Testing\TestCase as BaseTestCase; use Illuminate\Support\Carbon; +use Modules\Core\Database\Seeders\PermissionsSeeder; +use Modules\Core\Database\Seeders\RolesSeeder; +use Modules\Core\Enums\UserRole; use Modules\Core\Models\Company; use Modules\Core\Models\User; @@ -44,6 +47,14 @@ protected function setUp(): void $currentCompanyId = $this->user->getCurrentCompanyId(); session(['current_company_id' => $currentCompanyId]); + /* + * Resources gate every page on Spatie permissions (canViewAny etc.), + * so the test user needs the seeded client_admin permission set. + */ + (new PermissionsSeeder())->run(); + (new RolesSeeder())->run(); + $this->user->assignRole(UserRole::CUSTOMER_ADMIN->value); + $this->withoutExceptionHandling(); } diff --git a/Modules/Invoices/Filament/Company/Resources/Invoices/InvoiceResource.php b/Modules/Invoices/Filament/Company/Resources/Invoices/InvoiceResource.php index c2278efd8..209c4708f 100644 --- a/Modules/Invoices/Filament/Company/Resources/Invoices/InvoiceResource.php +++ b/Modules/Invoices/Filament/Company/Resources/Invoices/InvoiceResource.php @@ -9,6 +9,7 @@ use Illuminate\Database\Eloquent\Model; use Modules\Core\Enums\Permission; use Modules\Core\Filament\Company\Resources\BaseResource; +use Modules\Invoices\Filament\Company\Resources\Invoices\Pages\EditInvoice; use Modules\Invoices\Filament\Company\Resources\Invoices\Pages\ListInvoices; use Modules\Invoices\Filament\Company\Resources\Invoices\Schemas\InvoiceForm; use Modules\Invoices\Filament\Company\Resources\Invoices\Tables\InvoicesTable; @@ -61,6 +62,7 @@ public static function getPages(): array { return [ 'index' => ListInvoices::route('/'), + 'edit' => EditInvoice::route('/{record}/edit'), ]; } diff --git a/Modules/Invoices/Filament/Company/Resources/Invoices/Pages/EditInvoice.php b/Modules/Invoices/Filament/Company/Resources/Invoices/Pages/EditInvoice.php index 1a3c20ce8..e1bb1e4e5 100644 --- a/Modules/Invoices/Filament/Company/Resources/Invoices/Pages/EditInvoice.php +++ b/Modules/Invoices/Filament/Company/Resources/Invoices/Pages/EditInvoice.php @@ -2,9 +2,15 @@ namespace Modules\Invoices\Filament\Company\Resources\Invoices\Pages; +use Filament\Actions\Action; use Filament\Actions\DeleteAction; +use Filament\Notifications\Notification; use Filament\Resources\Pages\EditRecord; +use Filament\Support\Icons\Heroicon; use Illuminate\Database\Eloquent\Model; +use Illuminate\Support\HtmlString; +use InvalidArgumentException; +use Modules\Invoices\Enums\InvoiceStatus; use Modules\Invoices\Filament\Company\Resources\Invoices\InvoiceResource; use Modules\Invoices\Services\InvoiceService; @@ -12,11 +18,6 @@ class EditInvoice extends EditRecord { protected static string $resource = InvoiceResource::class; - public function mount($record): void - { - parent::mount($record); - } - public function save(bool $shouldRedirect = true, bool $shouldSendSavedNotification = true): void { $this->authorizeAccess(); @@ -49,7 +50,92 @@ protected function handleRecordUpdate(Model $record, array $data): Model protected function getHeaderActions(): array { return [ - DeleteAction::make(), + Action::make('preview') + ->label(trans('ip.preview')) + ->icon(Heroicon::OutlinedEye) + ->modalHeading(fn () => trans('ip.invoice') . ' ' . ($this->getRecord()->invoice_number ?? trans('ip.draft'))) + ->modalContent(fn () => new HtmlString( + app(InvoiceService::class)->renderHtml($this->getRecord()) + )) + ->modalSubmitAction(false) + ->modalCancelActionLabel(trans('ip.close')) + ->slideOver() + ->modalWidth('3xl'), + + Action::make('create_credit_note') + ->label(trans('ip.create_credit_note')) + ->icon(Heroicon::OutlinedDocumentMinus) + ->visible(fn () => in_array($this->getRecord()->invoice_status, [ + InvoiceStatus::SENT, + InvoiceStatus::PAID, + InvoiceStatus::PARTIALLY_PAID, + ])) + ->requiresConfirmation() + ->action(function () { + try { + $creditNote = app(InvoiceService::class)->createCreditNote($this->getRecord()); + } catch (InvalidArgumentException $e) { + Notification::make() + ->title($e->getMessage()) + ->danger() + ->send(); + + return; + } + + Notification::make() + ->title(trans('ip.credit_note_created')) + ->success() + ->send(); + + $this->redirect(InvoiceResource::getUrl('edit', ['record' => $creditNote])); + }), + + Action::make('download_pdf') + ->label(trans('ip.download_pdf')) + ->icon(Heroicon::OutlinedArrowDownTray) + ->action(fn () => app(InvoiceService::class)->generatePdf($this->getRecord())), + + Action::make('send_email') + ->label(trans('ip.send_email')) + ->icon(Heroicon::OutlinedEnvelope) + ->action(fn () => Notification::make() + ->title(trans('ip.not_yet_implemented')) + ->warning() + ->send()), + + Action::make('create_recurring') + ->label(trans('ip.create_recurring')) + ->icon(Heroicon::OutlinedArrowPath) + ->action(fn () => Notification::make() + ->title(trans('ip.not_yet_implemented')) + ->warning() + ->send()), + + Action::make('copy_invoice') + ->label(trans('ip.copy_invoice')) + ->icon(Heroicon::OutlinedDocumentDuplicate) + ->action(function (): void { + $original = $this->getRecord(); + $copy = $original->replicate(['invoice_number', 'invoice_status']); + $copy->invoice_status = InvoiceStatus::DRAFT; + $copy->invoice_number = null; + $copy->save(); + + foreach ($original->invoiceItems as $item) { + $copy->invoiceItems()->create($item->only([ + 'product_id', 'item_name', 'description', + 'quantity', 'price', 'discount', + 'tax_rate_id', 'tax_rate_2_id', + ])); + } + + $this->redirect(InvoiceResource::getUrl('edit', ['record' => $copy])); + }), + + DeleteAction::make() + ->hidden(fn () => $this->getRecord()->invoice_status === InvoiceStatus::PAID + || $this->getRecord()->is_read_only), ]; } } diff --git a/Modules/Invoices/Services/InvoiceService.php b/Modules/Invoices/Services/InvoiceService.php index f1e2e499c..52c5970ae 100644 --- a/Modules/Invoices/Services/InvoiceService.php +++ b/Modules/Invoices/Services/InvoiceService.php @@ -7,8 +7,10 @@ use Illuminate\Support\Str; use InvalidArgumentException; use Modules\Core\Services\BaseService; +use Modules\Core\Support\PDF\PDFFactory; use Modules\Invoices\Enums\InvoiceStatus; use Modules\Invoices\Models\Invoice; +use Symfony\Component\HttpFoundation\StreamedResponse; use Throwable; class InvoiceService extends BaseService @@ -197,6 +199,97 @@ public function deleteInvoice(Invoice $invoice): Invoice return $invoice; } + /** + * Render the invoice document markup used by both the PDF driver and + * the on-screen preview. + */ + public function renderHtml(Invoice $invoice): string + { + $invoice->loadMissing(['company', 'customer', 'invoiceItems']); + + return view('invoices::pdf.invoice', ['invoice' => $invoice])->render(); + } + + /** + * Stream the invoice as a PDF download named after the invoice number. + */ + public function generatePdf(Invoice $invoice): StreamedResponse + { + $driver = PDFFactory::create(); + $output = $driver->getOutput($this->renderHtml($invoice)); + $filename = ($invoice->invoice_number ?: 'invoice-draft-' . $invoice->id) . '.pdf'; + + return response()->streamDownload( + function () use ($output): void { + echo $output; + }, + $filename, + ['Content-Type' => 'application/pdf'], + ); + } + + /** + * Issue a credit note for a sent/paid invoice: a mirrored draft invoice + * with negated amounts linked via creditinvoice_parent_id. The credit + * note may share the parent's number (the duplicate-number guard allows + * this), but starts unnumbered like every draft. + */ + public function createCreditNote(Invoice $invoice): Invoice + { + if ($invoice->creditinvoice_parent_id !== null) { + throw new InvalidArgumentException(trans('ip.cannot_credit_a_credit_note')); + } + + return DB::transaction(function () use ($invoice) { + $creditNote = Invoice::query()->create([ + 'company_id' => $invoice->company_id, + 'customer_id' => $invoice->customer_id, + 'numbering_id' => $invoice->numbering_id, + 'creditinvoice_parent_id' => $invoice->id, + 'user_id' => auth()->id() ?? $invoice->user_id, + 'invoice_number' => null, + 'invoice_status' => InvoiceStatus::DRAFT->value, + 'invoice_sign' => '-1', + 'invoiced_at' => Carbon::today(), + 'invoice_due_at' => Carbon::today()->addDays(30), + 'invoice_discount_amount' => -1 * (float) $invoice->invoice_discount_amount, + 'invoice_discount_percent' => $invoice->invoice_discount_percent, + 'item_tax_total' => -1 * (float) $invoice->item_tax_total, + 'invoice_item_subtotal' => -1 * (float) $invoice->invoice_item_subtotal, + 'invoice_tax_total' => -1 * (float) $invoice->invoice_tax_total, + 'invoice_total' => -1 * (float) $invoice->invoice_total, + 'url_key' => Str::random(32), + 'summary' => $invoice->summary, + 'terms' => $invoice->terms, + 'footer' => $invoice->footer, + ]); + + foreach ($invoice->invoiceItems as $item) { + $creditNote->invoiceItems()->create([ + 'company_id' => $item->company_id, + 'product_id' => $item->product_id, + 'product_unit_id' => $item->product_unit_id, + 'task_id' => $item->task_id, + 'added_at' => Carbon::today()->toDateString(), + 'item_name' => $item->item_name, + 'description' => $item->description, + 'quantity' => $item->quantity, + 'price' => -1 * (float) $item->price, + 'discount' => $item->discount, + 'subtotal' => -1 * (float) $item->subtotal, + 'tax_1' => -1 * (float) $item->tax_1, + 'tax_2' => -1 * (float) $item->tax_2, + 'tax_total' => -1 * (float) $item->tax_total, + 'total' => -1 * (float) $item->total, + 'tax_rate_id' => $item->tax_rate_id, + 'tax_rate_2_id' => $item->tax_rate_2_id, + ]); + } + + return $creditNote; + }); + } + private function calculateItemTaxTotal(array $data): float { return collect($data['invoiceItems'] ?? [])->sum(fn ($item) => $item['tax'] ?? 0); diff --git a/Modules/Invoices/Tests/Feature/EditInvoiceHeaderActionsTest.php b/Modules/Invoices/Tests/Feature/EditInvoiceHeaderActionsTest.php new file mode 100644 index 000000000..2ccf7ce49 --- /dev/null +++ b/Modules/Invoices/Tests/Feature/EditInvoiceHeaderActionsTest.php @@ -0,0 +1,157 @@ +run(); + (new RolesSeeder())->run(); + $this->user->assignRole(UserRole::CUSTOMER_ADMIN->value); + } + + #[Test] + #[Group('crud')] + public function it_hides_create_credit_note_and_shows_delete_on_draft_invoice(): void + { + /* Arrange */ + $invoice = $this->createInvoice(InvoiceStatus::DRAFT); + + /* Act */ + $component = Livewire::actingAs($this->user) + ->test(EditInvoice::class, ['record' => $invoice->id]); + + /* Assert */ + $component + ->assertSuccessful() + ->assertActionHidden('create_credit_note') + ->assertActionVisible('download_pdf') + ->assertActionVisible('send_email') + ->assertActionVisible('create_recurring') + ->assertActionVisible('copy_invoice') + ->assertActionVisible('delete'); + } + + #[Test] + #[Group('crud')] + public function it_shows_create_credit_note_and_hides_delete_on_paid_invoice(): void + { + /* Arrange */ + $invoice = $this->createInvoice(InvoiceStatus::PAID); + + /* Act */ + $component = Livewire::actingAs($this->user) + ->test(EditInvoice::class, ['record' => $invoice->id]); + + /* Assert */ + $component + ->assertSuccessful() + ->assertActionVisible('create_credit_note') + ->assertActionHidden('delete'); + } + + #[Test] + #[Group('crud')] + public function it_shows_create_credit_note_on_sent_invoice(): void + { + /* Arrange */ + $invoice = $this->createInvoice(InvoiceStatus::SENT); + + /* Act */ + $component = Livewire::actingAs($this->user) + ->test(EditInvoice::class, ['record' => $invoice->id]); + + /* Assert */ + $component + ->assertSuccessful() + ->assertActionVisible('create_credit_note') + ->assertActionVisible('delete'); + } + + #[Test] + #[Group('crud')] + public function it_hides_delete_on_read_only_invoice(): void + { + /* Arrange */ + $invoice = $this->createInvoice(InvoiceStatus::DRAFT, ['is_read_only' => true]); + + /* Act */ + $component = Livewire::actingAs($this->user) + ->test(EditInvoice::class, ['record' => $invoice->id]); + + /* Assert */ + $component + ->assertSuccessful() + ->assertActionHidden('delete'); + } + + #[Test] + #[Group('crud')] + public function it_copies_invoice_as_new_draft(): void + { + /* Arrange */ + $invoice = $this->createInvoice(InvoiceStatus::PAID); + $invoice->invoiceItems()->create([ + 'item_name' => 'Copied item', + 'quantity' => 2, + 'price' => 100, + 'discount' => 0, + ]); + + /* Act */ + $component = Livewire::actingAs($this->user) + ->test(EditInvoice::class, ['record' => $invoice->id]) + ->callAction('copy_invoice'); + + /* Assert */ + $component->assertSuccessful(); + + $copy = Invoice::query() + ->whereKeyNot($invoice->id) + ->where('customer_id', $invoice->customer_id) + ->firstOrFail(); + + $this->assertSame(InvoiceStatus::DRAFT, $copy->invoice_status); + $this->assertCount(1, $copy->invoiceItems); + $this->assertSame('Copied item', $copy->invoiceItems->first()->item_name); + } + + private function createInvoice(InvoiceStatus $status, array $attributes = []): Invoice + { + $customer = Relation::factory()->for($this->company)->customer()->create(); + $documentGroup = Numbering::factory()->for($this->company)->create(); + + return Invoice::factory()->for($this->company)->create(array_merge([ + 'invoice_number' => 'INV-987654', + 'customer_id' => $customer->getKey(), + 'numbering_id' => $documentGroup->getKey(), + 'user_id' => $this->user->id, + 'invoice_status' => $status->value, + 'is_read_only' => false, + 'invoiced_at' => '2025-05-10', + 'invoice_due_at' => '2025-06-09', + ], $attributes)); + } +} diff --git a/Modules/Invoices/Tests/Feature/InvoicePdfAndCreditNoteTest.php b/Modules/Invoices/Tests/Feature/InvoicePdfAndCreditNoteTest.php new file mode 100644 index 000000000..5ad963b08 --- /dev/null +++ b/Modules/Invoices/Tests/Feature/InvoicePdfAndCreditNoteTest.php @@ -0,0 +1,178 @@ +run(); + (new RolesSeeder())->run(); + $this->user->assignRole(UserRole::CUSTOMER_ADMIN->value); + + $this->actingAs($this->user); + $this->service = app(InvoiceService::class); + } + + #[Test] + #[Group('crud')] + public function it_renders_invoice_html_with_number_and_customer(): void + { + /* Arrange */ + $invoice = $this->createInvoice(InvoiceStatus::SENT); + $invoice->invoiceItems()->create([ + 'item_name' => 'Widget', + 'quantity' => 3, + 'price' => 25, + 'discount' => 0, + 'subtotal' => 75, + ]); + + /* Act */ + $html = $this->service->renderHtml($invoice); + + /* Assert */ + $this->assertStringContainsString('INV-987654', $html); + $this->assertStringContainsString($invoice->customer->company_name, $html); + $this->assertStringContainsString('Widget', $html); + $this->assertStringNotContainsString('createInvoice(InvoiceStatus::SENT); + + /* Act */ + $output = PDFFactory::create()->getOutput($this->service->renderHtml($invoice)); + $response = $this->service->generatePdf($invoice); + + /* Assert */ + $this->assertStringStartsWith('%PDF', $output); + $this->assertInstanceOf(StreamedResponse::class, $response); + $this->assertStringContainsString('INV-987654.pdf', (string) $response->headers->get('Content-Disposition')); + $this->assertSame('application/pdf', $response->headers->get('Content-Type')); + } + + #[Test] + #[Group('crud')] + public function it_shows_the_preview_modal_on_the_edit_page(): void + { + /* Arrange */ + $invoice = $this->createInvoice(InvoiceStatus::SENT); + + /* Act + Assert */ + Livewire::actingAs($this->user) + ->test(EditInvoice::class, ['record' => $invoice->id]) + ->assertSuccessful() + ->mountAction('preview') + ->assertActionMounted('preview'); + } + + #[Test] + #[Group('crud')] + public function it_creates_a_credit_note_from_a_paid_invoice(): void + { + /* Arrange */ + $invoice = $this->createInvoice(InvoiceStatus::PAID, [ + 'invoice_item_subtotal' => 100, + 'invoice_total' => 100, + ]); + $invoice->invoiceItems()->create([ + 'item_name' => 'Widget', + 'quantity' => 1, + 'price' => 100, + 'discount' => 0, + 'subtotal' => 100, + ]); + + /* Act */ + $creditNote = $this->service->createCreditNote($invoice); + + /* Assert */ + $this->assertSame($invoice->id, $creditNote->creditinvoice_parent_id); + $this->assertSame(InvoiceStatus::DRAFT, $creditNote->invoice_status); + $this->assertNull($creditNote->invoice_number); + $this->assertEqualsWithDelta(-100.0, (float) $creditNote->invoice_total, 0.001); + $this->assertEqualsWithDelta(-100.0, (float) $creditNote->invoiceItems->first()->price, 0.001); + } + + #[Test] + #[Group('crud')] + public function it_refuses_to_credit_a_credit_note(): void + { + /* Arrange */ + $invoice = $this->createInvoice(InvoiceStatus::PAID); + $creditNote = $this->service->createCreditNote($invoice); + + /* Assert */ + $this->expectException(InvalidArgumentException::class); + + /* Act */ + $this->service->createCreditNote($creditNote); + } + + #[Test] + #[Group('crud')] + public function it_runs_the_credit_note_action_from_the_edit_page(): void + { + /* Arrange */ + $invoice = $this->createInvoice(InvoiceStatus::PAID); + + /* Act */ + $component = Livewire::actingAs($this->user) + ->test(EditInvoice::class, ['record' => $invoice->id]) + ->callAction('create_credit_note'); + + /* Assert */ + $component->assertSuccessful(); + $this->assertDatabaseHas('invoices', [ + 'creditinvoice_parent_id' => $invoice->id, + 'invoice_status' => InvoiceStatus::DRAFT->value, + ]); + } + + private function createInvoice(InvoiceStatus $status, array $attributes = []): Invoice + { + $customer = Relation::factory()->for($this->company)->customer()->create(); + $documentGroup = Numbering::factory()->for($this->company)->create(); + + return Invoice::factory()->for($this->company)->create(array_merge([ + 'invoice_number' => 'INV-987654', + 'customer_id' => $customer->getKey(), + 'numbering_id' => $documentGroup->getKey(), + 'user_id' => $this->user->id, + 'invoice_status' => $status->value, + 'is_read_only' => false, + 'invoiced_at' => '2025-05-10', + 'invoice_due_at' => '2025-06-09', + ], $attributes)); + } +} diff --git a/Modules/Invoices/Tests/Feature/InvoicesTest.php b/Modules/Invoices/Tests/Feature/InvoicesTest.php index cd401fec8..8d50d5708 100644 --- a/Modules/Invoices/Tests/Feature/InvoicesTest.php +++ b/Modules/Invoices/Tests/Feature/InvoicesTest.php @@ -8,6 +8,7 @@ use Illuminate\Support\Str; use Livewire\Livewire; use Modules\Clients\Models\Relation; +use Modules\Core\Enums\NumberingType; use Modules\Core\Models\Numbering; use Modules\Core\Models\TaxRate; use Modules\Core\Tests\AbstractCompanyPanelTestCase; @@ -38,7 +39,7 @@ public function it_lists_invoices(): void /* Arrange */ $user = $this->user; $customer = Relation::factory()->for($this->company)->customer()->create(); - $documentGroup = Numbering::factory()->for($this->company)->create(); + $documentGroup = Numbering::factory()->for($this->company)->state(['type' => NumberingType::INVOICE->value])->create(); $taxRate = TaxRate::factory()->for($this->company)->create(); $productCategory = ProductCategory::factory()->for($this->company)->create(); $productUnit = ProductUnit::factory()->for($this->company)->create(); @@ -87,7 +88,7 @@ public function it_creates_an_invoice_through_a_modal(): void { /* Arrange */ $customer = Relation::factory()->for($this->company)->customer()->create(); - $documentGroup = Numbering::factory()->for($this->company)->create(); + $documentGroup = Numbering::factory()->for($this->company)->state(['type' => NumberingType::INVOICE->value])->create(); $taxRate = TaxRate::factory()->for($this->company)->create(); $productCategory = ProductCategory::factory()->for($this->company)->create(); $productUnit = ProductUnit::factory()->for($this->company)->create(); @@ -141,7 +142,7 @@ public function it_fails_to_create_invoice_through_a_modal_without_required_invo { /* Arrange */ $customer = Relation::factory()->for($this->company)->customer()->create(); - $documentGroup = Numbering::factory()->for($this->company)->create(); + $documentGroup = Numbering::factory()->for($this->company)->state(['type' => NumberingType::INVOICE->value])->create(); $taxRate = TaxRate::factory()->for($this->company)->create(); $productCategory = ProductCategory::factory()->for($this->company)->create(); $productUnit = ProductUnit::factory()->for($this->company)->create(); @@ -186,7 +187,7 @@ public function it_fails_to_create_invoice_through_a_modal_without_required_invo { /* Arrange */ $customer = Relation::factory()->for($this->company)->customer()->create(); - $documentGroup = Numbering::factory()->for($this->company)->create(); + $documentGroup = Numbering::factory()->for($this->company)->state(['type' => NumberingType::INVOICE->value])->create(); $taxRate = TaxRate::factory()->for($this->company)->create(); $productCategory = ProductCategory::factory()->for($this->company)->create(); $productUnit = ProductUnit::factory()->for($this->company)->create(); @@ -229,7 +230,7 @@ public function it_fails_to_create_invoice_through_a_modal_without_required_cust { /* Arrange */ $customer = Relation::factory()->for($this->company)->customer()->create(); - $documentGroup = Numbering::factory()->for($this->company)->create(); + $documentGroup = Numbering::factory()->for($this->company)->state(['type' => NumberingType::INVOICE->value])->create(); $taxRate = TaxRate::factory()->for($this->company)->create(); $productCategory = ProductCategory::factory()->for($this->company)->create(); $productUnit = ProductUnit::factory()->for($this->company)->create(); @@ -274,7 +275,7 @@ public function it_updates_an_invoice_through_a_modal(): void { /* Arrange */ $customer = Relation::factory()->for($this->company)->customer()->create(); - $documentGroup = Numbering::factory()->for($this->company)->create(); + $documentGroup = Numbering::factory()->for($this->company)->state(['type' => NumberingType::INVOICE->value])->create(); $taxRate = TaxRate::factory()->for($this->company)->create(); $productCategory = ProductCategory::factory()->for($this->company)->create(); $productUnit = ProductUnit::factory()->for($this->company)->create(); @@ -325,7 +326,7 @@ public function it_updates_an_invoice_through_a_modal(): void public function it_creates_an_invoice_with_items(): void { $customer = Relation::factory()->for($this->company)->customer()->create(); - $documentGroup = Numbering::factory()->for($this->company)->create(); + $documentGroup = Numbering::factory()->for($this->company)->state(['type' => NumberingType::INVOICE->value])->create(); $taxRate = TaxRate::factory()->for($this->company)->create(); $productCategory = ProductCategory::factory()->for($this->company)->create(); $productUnit = ProductUnit::factory()->for($this->company)->create(); @@ -381,7 +382,7 @@ public function it_fails_to_create_invoice_without_required_invoice_number(): vo /* Arrange */ $user = $this->user; $customer = Relation::factory()->for($this->company)->customer()->create(); - $documentGroup = Numbering::factory()->for($this->company)->create(); + $documentGroup = Numbering::factory()->for($this->company)->state(['type' => NumberingType::INVOICE->value])->create(); $taxRate = TaxRate::factory()->for($this->company)->create(); $productCategory = ProductCategory::factory()->for($this->company)->create(); $productUnit = ProductUnit::factory()->for($this->company)->create(); @@ -425,7 +426,7 @@ public function it_fails_to_create_invoice_without_required_invoice_status(): vo /* Arrange */ $user = $this->user; $customer = Relation::factory()->for($this->company)->customer()->create(); - $documentGroup = Numbering::factory()->for($this->company)->create(); + $documentGroup = Numbering::factory()->for($this->company)->state(['type' => NumberingType::INVOICE->value])->create(); $taxRate = TaxRate::factory()->for($this->company)->create(); $productCategory = ProductCategory::factory()->for($this->company)->create(); $productUnit = ProductUnit::factory()->for($this->company)->create(); @@ -467,7 +468,7 @@ public function it_fails_to_create_invoice_without_required_customer(): void /* Arrange */ $user = $this->user; $customer = Relation::factory()->for($this->company)->customer()->create(); - $documentGroup = Numbering::factory()->for($this->company)->create(); + $documentGroup = Numbering::factory()->for($this->company)->state(['type' => NumberingType::INVOICE->value])->create(); $taxRate = TaxRate::factory()->for($this->company)->create(); $productCategory = ProductCategory::factory()->for($this->company)->create(); $productUnit = ProductUnit::factory()->for($this->company)->create(); @@ -510,7 +511,7 @@ public function it_updates_an_invoice(): void { /* Arrange */ $customer = Relation::factory()->for($this->company)->customer()->create(); - $documentGroup = Numbering::factory()->for($this->company)->create(); + $documentGroup = Numbering::factory()->for($this->company)->state(['type' => NumberingType::INVOICE->value])->create(); $taxRate = TaxRate::factory()->for($this->company)->create(); $productCategory = ProductCategory::factory()->for($this->company)->create(); $productUnit = ProductUnit::factory()->for($this->company)->create(); @@ -586,7 +587,7 @@ public function it_deletes_an_invoice(): void /* Arrange */ $user = $this->user; $customer = Relation::factory()->for($this->company)->customer()->create(); - $documentGroup = Numbering::factory()->for($this->company)->create(); + $documentGroup = Numbering::factory()->for($this->company)->state(['type' => NumberingType::INVOICE->value])->create(); $taxRate = TaxRate::factory()->for($this->company)->create(); $productCategory = ProductCategory::factory()->for($this->company)->create(); $productUnit = ProductUnit::factory()->for($this->company)->create(); diff --git a/Modules/Invoices/resources/views/pdf/invoice.blade.php b/Modules/Invoices/resources/views/pdf/invoice.blade.php new file mode 100644 index 000000000..15e6bc1a8 --- /dev/null +++ b/Modules/Invoices/resources/views/pdf/invoice.blade.php @@ -0,0 +1,97 @@ +{{-- Invoice document markup — used by the PDF driver and the on-screen preview. --}} +
+ + + + + +
+
{{ $invoice->company?->name }}
+
+
{{ trans('ip.invoice') }}
+
{{ $invoice->invoice_number ?? trans('ip.draft') }}
+
+ + + + + + +
+
{{ trans('ip.bill_to') }}
+
{{ $invoice->customer?->company_name }}
+ @if ($invoice->customer?->vat_number) +
{{ trans('ip.vat_id_short') }}: {{ $invoice->customer->vat_number }}
+ @endif +
+
{{ trans('ip.invoice_date') }}: {{ $invoice->invoiced_at?->format('Y-m-d') }}
+
{{ trans('ip.invoice_due_at') }}: {{ $invoice->invoice_due_at?->format('Y-m-d') }}
+
+ + + + + + + + + + + + + @foreach ($invoice->invoiceItems as $item) + + + + + + + + @endforeach + +
{{ trans('ip.item') }}{{ trans('ip.quantity') }}{{ trans('ip.price') }}{{ trans('ip.discount') }}{{ trans('ip.subtotal') }}
+ {{ $item->item_name }} + @if ($item->description) +
{{ $item->description }}
+ @endif +
{{ $item->quantity + 0 }}{{ number_format((float) $item->price, 2) }}{{ number_format((float) $item->discount, 2) }}{{ number_format((float) $item->subtotal, 2) }}
+ + + + + + + + + + + @if ((float) $invoice->invoice_discount_amount > 0) + + + + + @endif + + + + +
{{ trans('ip.subtotal') }}{{ number_format((float) $invoice->invoice_item_subtotal, 2) }}
{{ trans('ip.tax') }}{{ number_format((float) $invoice->invoice_tax_total + (float) $invoice->item_tax_total, 2) }}
{{ trans('ip.discount') }}-{{ number_format((float) $invoice->invoice_discount_amount, 2) }}
{{ trans('ip.total') }}{{ number_format((float) $invoice->invoice_total, 2) }}
+ + @if ($invoice->summary) +
+
{{ trans('ip.summary') }}
+
{{ $invoice->summary }}
+
+ @endif + + @if ($invoice->terms) +
+
{{ trans('ip.terms') }}
+
{{ $invoice->terms }}
+
+ @endif + + @if ($invoice->footer) +
{{ $invoice->footer }}
+ @endif +
diff --git a/Modules/Quotes/Tests/Feature/QuotesTest.php b/Modules/Quotes/Tests/Feature/QuotesTest.php index 2fdfdce56..5b3e5baf2 100644 --- a/Modules/Quotes/Tests/Feature/QuotesTest.php +++ b/Modules/Quotes/Tests/Feature/QuotesTest.php @@ -7,6 +7,7 @@ use Illuminate\Support\Str; use Livewire\Livewire; use Modules\Clients\Models\Relation; +use Modules\Core\Enums\NumberingType; use Modules\Core\Models\Numbering; use Modules\Core\Models\TaxRate; use Modules\Core\Tests\AbstractCompanyPanelTestCase; @@ -65,7 +66,7 @@ public function it_creates_a_quote_through_a_modal(): void { /* Arrange */ $prospect = Relation::factory()->for($this->company)->prospect()->create(); - $documentGroup = Numbering::factory()->for($this->company)->create(); + $documentGroup = Numbering::factory()->for($this->company)->state(['type' => NumberingType::QUOTE->value])->create(); $taxRate = TaxRate::factory()->for($this->company)->create(); $productCategory = ProductCategory::factory()->for($this->company)->create(); @@ -139,7 +140,7 @@ public function it_creates_a_quote_through_a_modal(): void public function it_fails_to_create_a_quote_through_a_modal_without_required_prospect(): void { /* Arrange */ - $documentGroup = Numbering::factory()->for($this->company)->create(); + $documentGroup = Numbering::factory()->for($this->company)->state(['type' => NumberingType::QUOTE->value])->create(); $taxRate = TaxRate::factory()->for($this->company)->create(); $productCategory = ProductCategory::factory()->for($this->company)->create(); @@ -295,7 +296,7 @@ public function it_updates_a_quote_through_a_modal(): void { /* Arrange */ $prospect = Relation::factory()->for($this->company)->prospect()->create(); - $documentGroup = Numbering::factory()->for($this->company)->create(); + $documentGroup = Numbering::factory()->for($this->company)->state(['type' => NumberingType::QUOTE->value])->create(); $quote = Quote::factory() ->for($this->company) @@ -348,7 +349,7 @@ public function it_creates_a_quote(): void { /* Arrange */ $prospect = Relation::factory()->for($this->company)->prospect()->create(); - $documentGroup = Numbering::factory()->for($this->company)->create(); + $documentGroup = Numbering::factory()->for($this->company)->state(['type' => NumberingType::QUOTE->value])->create(); $taxRate = TaxRate::factory()->for($this->company)->create(); $productCategory = ProductCategory::factory()->for($this->company)->create(); diff --git a/composer.json b/composer.json index b3603f84c..ce57a302a 100644 --- a/composer.json +++ b/composer.json @@ -20,6 +20,7 @@ "php": "^8.2", "awcodes/mason": "^3.1", "doctrine/dbal": ">=4.4", + "dompdf/dompdf": "^3.1", "filament/actions": ">=5.6", "filament/filament": ">=5.6", "laravel/framework": ">=12.46", diff --git a/composer.lock b/composer.lock index c2a995701..0881feb75 100644 --- a/composer.lock +++ b/composer.lock @@ -4,7 +4,7 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "fe150517400be01530b4df0d6d777ab7", + "content-hash": "b1c1bdcb45c10adfddbf619a66c2a6c3", "packages": [ { "name": "awcodes/mason", @@ -1181,6 +1181,161 @@ ], "time": "2024-02-05T11:56:58+00:00" }, + { + "name": "dompdf/dompdf", + "version": "v3.1.5", + "source": { + "type": "git", + "url": "https://github.com/dompdf/dompdf.git", + "reference": "f11ead23a8a76d0ff9bbc6c7c8fd7e05ca328496" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/dompdf/dompdf/zipball/f11ead23a8a76d0ff9bbc6c7c8fd7e05ca328496", + "reference": "f11ead23a8a76d0ff9bbc6c7c8fd7e05ca328496", + "shasum": "" + }, + "require": { + "dompdf/php-font-lib": "^1.0.0", + "dompdf/php-svg-lib": "^1.0.0", + "ext-dom": "*", + "ext-mbstring": "*", + "masterminds/html5": "^2.0", + "php": "^7.1 || ^8.0" + }, + "require-dev": { + "ext-gd": "*", + "ext-json": "*", + "ext-zip": "*", + "mockery/mockery": "^1.3", + "phpunit/phpunit": "^7.5 || ^8 || ^9 || ^10 || ^11", + "squizlabs/php_codesniffer": "^3.5", + "symfony/process": "^4.4 || ^5.4 || ^6.2 || ^7.0" + }, + "suggest": { + "ext-gd": "Needed to process images", + "ext-gmagick": "Improves image processing performance", + "ext-imagick": "Improves image processing performance", + "ext-zlib": "Needed for pdf stream compression" + }, + "type": "library", + "autoload": { + "psr-4": { + "Dompdf\\": "src/" + }, + "classmap": [ + "lib/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "LGPL-2.1" + ], + "authors": [ + { + "name": "The Dompdf Community", + "homepage": "https://github.com/dompdf/dompdf/blob/master/AUTHORS.md" + } + ], + "description": "DOMPDF is a CSS 2.1 compliant HTML to PDF converter", + "homepage": "https://github.com/dompdf/dompdf", + "support": { + "issues": "https://github.com/dompdf/dompdf/issues", + "source": "https://github.com/dompdf/dompdf/tree/v3.1.5" + }, + "time": "2026-03-03T13:54:37+00:00" + }, + { + "name": "dompdf/php-font-lib", + "version": "1.0.2", + "source": { + "type": "git", + "url": "https://github.com/dompdf/php-font-lib.git", + "reference": "a6e9a688a2a80016ac080b97be73d3e10c444c9a" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/dompdf/php-font-lib/zipball/a6e9a688a2a80016ac080b97be73d3e10c444c9a", + "reference": "a6e9a688a2a80016ac080b97be73d3e10c444c9a", + "shasum": "" + }, + "require": { + "ext-mbstring": "*", + "php": "^7.1 || ^8.0" + }, + "require-dev": { + "phpunit/phpunit": "^7.5 || ^8 || ^9 || ^10 || ^11 || ^12" + }, + "type": "library", + "autoload": { + "psr-4": { + "FontLib\\": "src/FontLib" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "LGPL-2.1-or-later" + ], + "authors": [ + { + "name": "The FontLib Community", + "homepage": "https://github.com/dompdf/php-font-lib/blob/master/AUTHORS.md" + } + ], + "description": "A library to read, parse, export and make subsets of different types of font files.", + "homepage": "https://github.com/dompdf/php-font-lib", + "support": { + "issues": "https://github.com/dompdf/php-font-lib/issues", + "source": "https://github.com/dompdf/php-font-lib/tree/1.0.2" + }, + "time": "2026-01-20T14:10:26+00:00" + }, + { + "name": "dompdf/php-svg-lib", + "version": "1.0.2", + "source": { + "type": "git", + "url": "https://github.com/dompdf/php-svg-lib.git", + "reference": "8259ffb930817e72b1ff1caef5d226501f3dfeb1" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/dompdf/php-svg-lib/zipball/8259ffb930817e72b1ff1caef5d226501f3dfeb1", + "reference": "8259ffb930817e72b1ff1caef5d226501f3dfeb1", + "shasum": "" + }, + "require": { + "ext-mbstring": "*", + "php": "^7.1 || ^8.0", + "sabberworm/php-css-parser": "^8.4 || ^9.0" + }, + "require-dev": { + "phpunit/phpunit": "^7.5 || ^8 || ^9 || ^10 || ^11" + }, + "type": "library", + "autoload": { + "psr-4": { + "Svg\\": "src/Svg" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "LGPL-3.0-or-later" + ], + "authors": [ + { + "name": "The SvgLib Community", + "homepage": "https://github.com/dompdf/php-svg-lib/blob/master/AUTHORS.md" + } + ], + "description": "A library to read, parse and export to PDF SVG files.", + "homepage": "https://github.com/dompdf/php-svg-lib", + "support": { + "issues": "https://github.com/dompdf/php-svg-lib/issues", + "source": "https://github.com/dompdf/php-svg-lib/tree/1.0.2" + }, + "time": "2026-01-02T16:01:13+00:00" + }, { "name": "dragonmantank/cron-expression", "version": "v3.6.0", @@ -5777,6 +5932,86 @@ ], "time": "2026-03-19T10:36:26+00:00" }, + { + "name": "sabberworm/php-css-parser", + "version": "v9.4.0", + "source": { + "type": "git", + "url": "https://github.com/MyIntervals/PHP-CSS-Parser.git", + "reference": "fd3bf9fb173e0df649bc4e3e0d088a1b2417c08f" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/MyIntervals/PHP-CSS-Parser/zipball/fd3bf9fb173e0df649bc4e3e0d088a1b2417c08f", + "reference": "fd3bf9fb173e0df649bc4e3e0d088a1b2417c08f", + "shasum": "" + }, + "require": { + "ext-iconv": "*", + "php": "^7.2.0 || ~8.0.0 || ~8.1.0 || ~8.2.0 || ~8.3.0 || ~8.4.0 || ~8.5.0", + "thecodingmachine/safe": "^1.3 || ^2.5 || ^3.4" + }, + "require-dev": { + "php-parallel-lint/php-parallel-lint": "1.4.0", + "phpstan/extension-installer": "1.4.3", + "phpstan/phpstan": "1.12.33 || 2.2.2", + "phpstan/phpstan-phpunit": "1.4.2 || 2.0.16", + "phpstan/phpstan-strict-rules": "1.6.2 || 2.0.11", + "phpunit/phpunit": "8.5.52", + "rawr/phpunit-data-provider": "3.3.1", + "rector/rector": "1.2.10 || 2.4.6", + "rector/type-perfect": "1.0.0 || 2.1.3", + "squizlabs/php_codesniffer": "4.0.1", + "thecodingmachine/phpstan-safe-rule": "1.2.0 || 1.4.3" + }, + "suggest": { + "ext-mbstring": "for parsing UTF-8 CSS" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "9.5.x-dev" + } + }, + "autoload": { + "files": [ + "src/Rule/Rule.php", + "src/RuleSet/RuleContainer.php" + ], + "psr-4": { + "Sabberworm\\CSS\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Raphael Schweikert" + }, + { + "name": "Oliver Klee", + "email": "github@oliverklee.de" + }, + { + "name": "Jake Hotson", + "email": "jake.github@qzdesign.co.uk" + } + ], + "description": "Parser for CSS Files written in PHP", + "homepage": "https://www.sabberworm.com/blog/2010/6/10/php-css-parser", + "keywords": [ + "css", + "parser", + "stylesheet" + ], + "support": { + "issues": "https://github.com/MyIntervals/PHP-CSS-Parser/issues", + "source": "https://github.com/MyIntervals/PHP-CSS-Parser/tree/v9.4.0" + }, + "time": "2026-06-18T15:10:53+00:00" + }, { "name": "scrivo/highlight.php", "version": "v9.18.1.10", @@ -8790,6 +9025,149 @@ ], "time": "2026-03-30T13:44:50+00:00" }, + { + "name": "thecodingmachine/safe", + "version": "v3.4.0", + "source": { + "type": "git", + "url": "https://github.com/thecodingmachine/safe.git", + "reference": "705683a25bacf0d4860c7dea4d7947bfd09eea19" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/thecodingmachine/safe/zipball/705683a25bacf0d4860c7dea4d7947bfd09eea19", + "reference": "705683a25bacf0d4860c7dea4d7947bfd09eea19", + "shasum": "" + }, + "require": { + "php": "^8.1" + }, + "require-dev": { + "php-parallel-lint/php-parallel-lint": "^1.4", + "phpstan/phpstan": "^2", + "phpunit/phpunit": "^10", + "squizlabs/php_codesniffer": "^3.2" + }, + "type": "library", + "autoload": { + "files": [ + "lib/special_cases.php", + "generated/apache.php", + "generated/apcu.php", + "generated/array.php", + "generated/bzip2.php", + "generated/calendar.php", + "generated/classobj.php", + "generated/com.php", + "generated/cubrid.php", + "generated/curl.php", + "generated/datetime.php", + "generated/dir.php", + "generated/eio.php", + "generated/errorfunc.php", + "generated/exec.php", + "generated/fileinfo.php", + "generated/filesystem.php", + "generated/filter.php", + "generated/fpm.php", + "generated/ftp.php", + "generated/funchand.php", + "generated/gettext.php", + "generated/gmp.php", + "generated/gnupg.php", + "generated/hash.php", + "generated/ibase.php", + "generated/ibmDb2.php", + "generated/iconv.php", + "generated/image.php", + "generated/imap.php", + "generated/info.php", + "generated/inotify.php", + "generated/json.php", + "generated/ldap.php", + "generated/libxml.php", + "generated/lzf.php", + "generated/mailparse.php", + "generated/mbstring.php", + "generated/misc.php", + "generated/mysql.php", + "generated/mysqli.php", + "generated/network.php", + "generated/oci8.php", + "generated/opcache.php", + "generated/openssl.php", + "generated/outcontrol.php", + "generated/pcntl.php", + "generated/pcre.php", + "generated/pgsql.php", + "generated/posix.php", + "generated/ps.php", + "generated/pspell.php", + "generated/readline.php", + "generated/rnp.php", + "generated/rpminfo.php", + "generated/rrd.php", + "generated/sem.php", + "generated/session.php", + "generated/shmop.php", + "generated/sockets.php", + "generated/sodium.php", + "generated/solr.php", + "generated/spl.php", + "generated/sqlsrv.php", + "generated/ssdeep.php", + "generated/ssh2.php", + "generated/stream.php", + "generated/strings.php", + "generated/swoole.php", + "generated/uodbc.php", + "generated/uopz.php", + "generated/url.php", + "generated/var.php", + "generated/xdiff.php", + "generated/xml.php", + "generated/xmlrpc.php", + "generated/yaml.php", + "generated/yaz.php", + "generated/zip.php", + "generated/zlib.php" + ], + "classmap": [ + "lib/DateTime.php", + "lib/DateTimeImmutable.php", + "lib/Exceptions/", + "generated/Exceptions/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "description": "PHP core functions that throw exceptions instead of returning FALSE on error", + "support": { + "issues": "https://github.com/thecodingmachine/safe/issues", + "source": "https://github.com/thecodingmachine/safe/tree/v3.4.0" + }, + "funding": [ + { + "url": "https://github.com/OskarStark", + "type": "github" + }, + { + "url": "https://github.com/shish", + "type": "github" + }, + { + "url": "https://github.com/silasjoisten", + "type": "github" + }, + { + "url": "https://github.com/staabm", + "type": "github" + } + ], + "time": "2026-02-04T18:08:13+00:00" + }, { "name": "tijsverkoyen/css-to-inline-styles", "version": "v2.4.0", diff --git a/config/ip.php b/config/ip.php index 0579ddc14..e5dabf816 100644 --- a/config/ip.php +++ b/config/ip.php @@ -36,4 +36,11 @@ '3' => '3', ], 'export_version' => 2, + + /* + * PDF rendering — driver class name under Modules\Core\Support\PDF\Drivers. + */ + 'pdfDriver' => env('IP_PDF_DRIVER', 'domPDF'), + 'paperSize' => env('IP_PDF_PAPER_SIZE', 'a4'), + 'paperOrientation' => env('IP_PDF_PAPER_ORIENTATION', 'portrait'), ]; diff --git a/pint_output.log b/pint_output.log new file mode 100644 index 000000000..79f26c47c --- /dev/null +++ b/pint_output.log @@ -0,0 +1,5 @@ + + + No dirty files found. + + diff --git a/resources/lang/en/ip.php b/resources/lang/en/ip.php index e60cf5855..61592081d 100644 --- a/resources/lang/en/ip.php +++ b/resources/lang/en/ip.php @@ -97,6 +97,8 @@ 'cldr' => 'en', 'close' => 'Close', 'closed' => 'Closed', + 'credit_note_created' => 'Credit note created as a draft.', + 'cannot_credit_a_credit_note' => 'A credit note cannot be credited again.', 'column' => 'Column', 'company' => 'Company', 'confirm' => 'Confirm', @@ -615,83 +617,84 @@ #endregion #region INVOICES - 'copy_invoice' => 'Copy Invoice', - 'create_credit_invoice' => 'Create credit invoice', - 'create_credit_invoice_alert' => 'Creating a credit invoice will make the current invoice read-only which means you will not be able to edit the invoice anymore. The credit invoice will contain the current state with all items but with negative amounts and balances.', - 'create_invoice' => 'Create Invoice', - 'credit_invoice' => 'Credit Invoice', - 'credit_invoice_date' => 'Credit invoice date', - 'credit_invoice_details' => 'Credit invoice details', - 'credit_invoice_for_invoice' => 'Credit invoice for invoice', - 'default_invoice_group' => 'Default Invoice Group', - 'default_invoice_tax_rate' => 'Default Invoice Tax Rate', - 'default_invoice_tax_rate_placement' => 'Default Invoice Tax Rate Placement', - 'delete_invoice' => 'Delete Invoice', - 'delete_invoice_warning' => 'If you delete this invoice you will not be able to recover it later. Are you sure you want to permanently delete this invoice?', - 'email_invoice' => 'Email Invoice', - 'filter_invoices' => 'Filter Invoices', - 'generate_invoice_number_for_draft' => 'Generate the invoice number for draft invoices', - 'invoice' => 'Invoice', - 'invoice_aging' => 'Invoice Aging', - 'invoice_aging_16_30' => '16 - 30 Days', - 'invoice_aging_1_15' => '1 - 15 Days', - 'invoice_aging_above_30' => 'Above 30 Days', - 'invoice_already_paid' => 'This invoice was already paid.', - 'invoice_amounts' => 'Invoice Amounts', - 'invoice_archive' => 'Invoice Archive', - 'invoice_count' => 'Invoice Count', - 'invoice_date' => 'Invoice Date', - 'invoice_dates' => 'Invoice Dates', - 'invoice_deletion_forbidden' => 'Deleting invoices is forbidden. Please contact the administrator or consult the documentation.', - 'invoice_due_at' => 'Due Date', - 'invoice_group' => 'Invoice Group', - 'invoice_group_form' => 'Invoice Group Form', - 'invoice_groups' => 'Invoice Groups', - 'invoice_items' => 'Invoice Items', - 'invoice_logo' => 'Invoice Logo', - 'invoice_not_found' => 'Invoice Not Found', - 'invoice_number' => 'Invoice Number', - 'invoice_overview' => 'Invoice Overview', - 'invoice_overview_period' => 'Invoice Overview Period', - 'invoice_password' => 'PDF password (optional)', - 'invoice_pdf_include_zugferd' => 'Include ZUGFeRD', - 'invoice_pdf_include_zugferd_help' => 'Enabling this option will include ZUGFeRD XML in invoice PDFs, which is an XML standard for invoices. More information', - 'invoice_pre_password' => 'Invoice standard PDF password (optional)', - 'invoice_status' => 'Status', - 'invoice_status_draft' => 'Draft', - 'invoice_status_overdue' => 'Overdue', - 'invoice_status_paid' => 'Paid', - 'invoice_status_partially_paid' => 'Partially Paid', - 'invoice_status_sent' => 'Sent', - 'invoice_status_viewed' => 'Viewed', - 'cannot_delete_paid_invoice' => 'Paid invoices cannot be deleted.', - 'invoice_sumex' => 'Sumex', - 'invoice_sumex_canton' => 'Canton', - 'invoice_sumex_diagnosis' => 'Diagnosis', - 'invoice_sumex_help' => 'This options adds a menu entry in invoices to generate a TARMED / SUMEX1 semi compatible invoice. TARMED / SUMEX1 is a swiss standard for healthcares. More Info', - 'invoice_sumex_place' => 'Sumex Place', - 'invoice_sumex_place_association' => 'Association', - 'invoice_sumex_place_company' => 'Company', - 'invoice_sumex_place_hospital' => 'Hospital', - 'invoice_sumex_place_lab' => 'Lab', - 'invoice_sumex_place_practice' => 'Practice', - 'invoice_tax' => 'Invoice Tax', - 'invoice_tax_rate' => 'Invoice Tax Rate', - 'invoice_template' => 'Invoice Template', - 'invoice_templates' => 'Invoice Templates', - 'invoice_terms' => 'Invoice Terms', - 'invoiced' => 'Invoiced', - 'invoiceplane_news' => 'InvoicePlane News', - 'invoices' => 'Invoices', - 'invoices_due_after' => 'Invoices Due After (Days)', - 'mark_invoices_sent_pdf' => 'Mark invoices as sent when PDF is generated', - 'no_open_invoices' => 'This client has no open invoices. To bill an expense, the client must have at least one invoice which has not been paid in full.', - 'no_update_invoice_due_date_mail' => 'Disable the change of invoice date and due date before emailing', - 'open_invoices' => 'Open Invoices', - 'pdf_invoice_footer' => 'PDF Footer', - 'pdf_invoice_footer_hint' => 'You can enter any HTML here which will be displayed on the bottom of your PDF invoices.', - 'set_to_read_only' => 'Set the Invoice to read-only on', - 'view_invoices' => 'View Invoices', + 'copy_invoice' => 'Copy Invoice', + 'create_credit_invoice' => 'Create credit invoice', + 'create_credit_invoice_alert' => 'Creating a credit invoice will make the current invoice read-only which means you will not be able to edit the invoice anymore. The credit invoice will contain the current state with all items but with negative amounts and balances.', + 'create_invoice' => 'Create Invoice', + 'credit_invoice' => 'Credit Invoice', + 'credit_invoice_date' => 'Credit invoice date', + 'credit_invoice_details' => 'Credit invoice details', + 'credit_invoice_for_invoice' => 'Credit invoice for invoice', + 'default_invoice_group' => 'Default Invoice Group', + 'default_invoice_tax_rate' => 'Default Invoice Tax Rate', + 'default_invoice_tax_rate_placement' => 'Default Invoice Tax Rate Placement', + 'delete_invoice' => 'Delete Invoice', + 'delete_invoice_warning' => 'If you delete this invoice you will not be able to recover it later. Are you sure you want to permanently delete this invoice?', + 'email_invoice' => 'Email Invoice', + 'filter_invoices' => 'Filter Invoices', + 'generate_invoice_number_for_draft' => 'Generate the invoice number for draft invoices', + 'invoice' => 'Invoice', + 'invoice_aging' => 'Invoice Aging', + 'invoice_aging_16_30' => '16 - 30 Days', + 'invoice_aging_1_15' => '1 - 15 Days', + 'invoice_aging_above_30' => 'Above 30 Days', + 'invoice_already_paid' => 'This invoice was already paid.', + 'invoice_amounts' => 'Invoice Amounts', + 'invoice_archive' => 'Invoice Archive', + 'invoice_count' => 'Invoice Count', + 'invoice_date' => 'Invoice Date', + 'invoice_dates' => 'Invoice Dates', + 'invoice_deletion_forbidden' => 'Deleting invoices is forbidden. Please contact the administrator or consult the documentation.', + 'invoice_due_at' => 'Due Date', + 'invoice_group' => 'Invoice Group', + 'invoice_group_form' => 'Invoice Group Form', + 'invoice_groups' => 'Invoice Groups', + 'invoice_items' => 'Invoice Items', + 'invoice_logo' => 'Invoice Logo', + 'invoice_not_found' => 'Invoice Not Found', + 'invoice_number' => 'Invoice Number', + 'invoice_overview' => 'Invoice Overview', + 'invoice_overview_period' => 'Invoice Overview Period', + 'invoice_password' => 'PDF password (optional)', + 'invoice_pdf_include_zugferd' => 'Include ZUGFeRD', + 'invoice_pdf_include_zugferd_help' => 'Enabling this option will include ZUGFeRD XML in invoice PDFs, which is an XML standard for invoices. More information', + 'invoice_pre_password' => 'Invoice standard PDF password (optional)', + 'invoice_status' => 'Status', + 'invoice_status_draft' => 'Draft', + 'invoice_status_overdue' => 'Overdue', + 'invoice_status_paid' => 'Paid', + 'invoice_status_partially_paid' => 'Partially Paid', + 'invoice_status_sent' => 'Sent', + 'invoice_status_viewed' => 'Viewed', + 'cannot_delete_paid_invoice' => 'Paid invoices cannot be deleted.', + 'cannot_delete_client_has_linked_records' => 'This client cannot be deleted because it has linked invoices, quotes, expenses, tasks, or projects.', + 'invoice_sumex' => 'Sumex', + 'invoice_sumex_canton' => 'Canton', + 'invoice_sumex_diagnosis' => 'Diagnosis', + 'invoice_sumex_help' => 'This options adds a menu entry in invoices to generate a TARMED / SUMEX1 semi compatible invoice. TARMED / SUMEX1 is a swiss standard for healthcares. More Info', + 'invoice_sumex_place' => 'Sumex Place', + 'invoice_sumex_place_association' => 'Association', + 'invoice_sumex_place_company' => 'Company', + 'invoice_sumex_place_hospital' => 'Hospital', + 'invoice_sumex_place_lab' => 'Lab', + 'invoice_sumex_place_practice' => 'Practice', + 'invoice_tax' => 'Invoice Tax', + 'invoice_tax_rate' => 'Invoice Tax Rate', + 'invoice_template' => 'Invoice Template', + 'invoice_templates' => 'Invoice Templates', + 'invoice_terms' => 'Invoice Terms', + 'invoiced' => 'Invoiced', + 'invoiceplane_news' => 'InvoicePlane News', + 'invoices' => 'Invoices', + 'invoices_due_after' => 'Invoices Due After (Days)', + 'mark_invoices_sent_pdf' => 'Mark invoices as sent when PDF is generated', + 'no_open_invoices' => 'This client has no open invoices. To bill an expense, the client must have at least one invoice which has not been paid in full.', + 'no_update_invoice_due_date_mail' => 'Disable the change of invoice date and due date before emailing', + 'open_invoices' => 'Open Invoices', + 'pdf_invoice_footer' => 'PDF Footer', + 'pdf_invoice_footer_hint' => 'You can enter any HTML here which will be displayed on the bottom of your PDF invoices.', + 'set_to_read_only' => 'Set the Invoice to read-only on', + 'view_invoices' => 'View Invoices', #endregion #region EXPENSES @@ -1211,17 +1214,19 @@ 'vendor' => 'Vendor', 'task_description' => 'Task Description', - 'project_number' => 'Project Number', - 'project_name' => 'Project Name', - 'start_date' => 'Start Date', - 'end_date' => 'End Date', - 'in_progress' => 'In Progress', - 'quote_number' => 'Quote Number', - 'quoted_at' => 'Quote Date', - 'expires_at' => 'Expiry Date', - 'draft' => 'Draft', - 'task_name' => 'Task Name', - 'pending' => 'Pending', - 'number' => 'Number', + 'project_number' => 'Project Number', + 'project_name' => 'Project Name', + 'start_date' => 'Start Date', + 'end_date' => 'End Date', + 'in_progress' => 'In Progress', + 'quote_number' => 'Quote Number', + 'quoted_at' => 'Quote Date', + 'expires_at' => 'Expiry Date', + 'draft' => 'Draft', + 'task_name' => 'Task Name', + 'pending' => 'Pending', + 'number' => 'Number', + 'not_yet_implemented' => 'Not yet implemented', + 'create_credit_note' => 'Create Credit Note', #endregion ];