From 3fe01fff901abb4bff0efa2b5354da9bd1c05f57 Mon Sep 17 00:00:00 2001 From: Niels Drost Date: Sat, 4 Jul 2026 14:23:46 +0200 Subject: [PATCH] feat(invoices): complete invoice list row action group (#31, #167, #201, #530, #48) Co-Authored-By: Claude Fable 5 --- .gitignore | 5 + Modules/Clients/Models/Relation.php | 20 +- Modules/Core/Database/Seeders/RolesSeeder.php | 1 + .../Core/Tests/AbstractAdminPanelTestCase.php | 11 + .../Tests/AbstractCompanyPanelTestCase.php | 11 + .../Invoices/Tables/InvoicesTable.php | 89 +++++- .../Invoices/Services/InvoiceCopyService.php | 62 +++- .../Tests/Feature/InvoiceListActionsTest.php | 294 ++++++++++++++++++ .../Invoices/Tests/Feature/InvoicesTest.php | 25 +- Modules/Payments/Services/PaymentService.php | 49 +++ Modules/Quotes/Tests/Feature/QuotesTest.php | 9 +- composer.json | 6 +- pint_output.log | 5 + resources/lang/en/ip.php | 3 + 14 files changed, 567 insertions(+), 23 deletions(-) create mode 100644 Modules/Invoices/Tests/Feature/InvoiceListActionsTest.php create mode 100644 pint_output.log 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/Clients/Models/Relation.php b/Modules/Clients/Models/Relation.php index 42f190a86..c9bd2cce3 100644 --- a/Modules/Clients/Models/Relation.php +++ b/Modules/Clients/Models/Relation.php @@ -11,6 +11,7 @@ use Illuminate\Database\Eloquent\Relations\HasMany; use Illuminate\Database\Eloquent\Relations\MorphMany; use Modules\Clients\Database\Factories\RelationFactory; +use Modules\Clients\Enums\CommunicationType; use Modules\Clients\Enums\RelationStatus; use Modules\Clients\Enums\RelationType; use Modules\Core\Models\Company; @@ -173,9 +174,24 @@ public function users(): HasMany | Accessors |-------------------------------------------------------------------------- */ - public function getCustomerEmailAttribute() + /** + * The relation's email address, resolved from the primary contact's + * email communications (there is no email column on relations itself). + * A primary email communication wins over any other email communication. + */ + public function getCustomerEmailAttribute(): ?string { - return $this->email; + $contact = $this->primaryContact; + + if ( ! $contact) { + return null; + } + + $emails = $contact->communications + ->where('communication_type', CommunicationType::EMAIL->value); + + return $emails->firstWhere('is_primary', true)?->communication_value + ?? $emails->first()?->communication_value; } /*public function getPrimaryContactAttribute(): string 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/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/Tables/InvoicesTable.php b/Modules/Invoices/Filament/Company/Resources/Invoices/Tables/InvoicesTable.php index 09d7fc172..0bc12d879 100644 --- a/Modules/Invoices/Filament/Company/Resources/Invoices/Tables/InvoicesTable.php +++ b/Modules/Invoices/Filament/Company/Resources/Invoices/Tables/InvoicesTable.php @@ -8,14 +8,25 @@ use Filament\Actions\DeleteAction; use Filament\Actions\DeleteBulkAction; use Filament\Actions\EditAction; +use Filament\Forms\Components\DatePicker; +use Filament\Forms\Components\Placeholder; +use Filament\Forms\Components\Select; +use Filament\Forms\Components\TextInput; +use Filament\Notifications\Notification; use Filament\Tables\Columns\TextColumn; +use Filament\Tables\Filters\SelectFilter; use Filament\Tables\Table; use InvalidArgumentException; +use Modules\Core\Enums\NumberingType; use Modules\Core\Enums\Permission; +use Modules\Core\Models\Numbering; use Modules\Core\Support\DateHelpers; use Modules\Invoices\Enums\InvoiceStatus; use Modules\Invoices\Models\Invoice; +use Modules\Invoices\Services\InvoiceCopyService; use Modules\Invoices\Services\InvoiceService; +use Modules\Payments\Enums\PaymentMethod; +use Modules\Payments\Services\PaymentService; class InvoicesTable { @@ -74,7 +85,15 @@ public static function configure(Table $table): Table ->sortable() ->toggleable(), ]) - ->filters([]) + ->filters([ + SelectFilter::make('numbering_id') + ->label(trans('ip.numbering')) + ->options(fn (): array => Numbering::query() + ->where('type', NumberingType::INVOICE->value) + ->orderBy('name') + ->pluck('name', 'id') + ->toArray()), + ]) ->recordActions([ ActionGroup::make([ EditAction::make() @@ -106,6 +125,67 @@ public static function configure(Table $table): Table app(\Modules\Invoices\Services\InvoiceService::class)->updateInvoice($record, $data); }) ->modalWidth('full'), + Action::make('copy') + ->visible(fn () => auth()->user()?->can(Permission::DUPLICATE_INVOICES->value)) + ->label(trans('ip.copy_invoice')) + ->icon('heroicon-o-document-duplicate') + ->requiresConfirmation() + ->action(function (Invoice $record) { + app(InvoiceCopyService::class)->copy($record); + Notification::make() + ->title(trans('ip.invoice_copied')) + ->success() + ->send(); + }), + Action::make('enter_payment') + ->label(trans('ip.enter_payment')) + ->icon('heroicon-o-banknotes') + ->visible(fn (Invoice $record) => auth()->user()?->can(Permission::CREATE_PAYMENTS->value) + && in_array($record->invoice_status, [ + InvoiceStatus::SENT, + InvoiceStatus::VIEWED, + InvoiceStatus::PARTIALLY_PAID, + InvoiceStatus::OVERDUE, + ], true)) + ->schema([ + Placeholder::make('invoice') + ->label(trans('ip.invoice')) + ->content(fn (Invoice $record) => mb_trim( + ($record->invoice_number ?? '#' . $record->id) + . ' – ' . ($record->customer?->company_name ?? '') + )), + TextInput::make('payment_amount') + ->label(trans('ip.payment_amount')) + ->numeric() + ->minValue(0.01) + ->required(), + DatePicker::make('paid_at') + ->label(trans('ip.paid_at')) + ->required(), + Select::make('payment_method') + ->label(trans('ip.payment_method')) + ->options( + collect(PaymentMethod::cases()) + ->mapWithKeys(fn (PaymentMethod $method) => [ + $method->value => $method->label(), + ]) + ->toArray() + ) + ->native(false) + ->required(), + ]) + ->fillForm(fn (Invoice $record) => [ + 'payment_amount' => app(PaymentService::class)->amountOwed($record), + 'paid_at' => now()->toDateString(), + ]) + ->action(function (Invoice $record, array $data): void { + app(PaymentService::class)->enterInvoicePayment($record, $data); + + Notification::make() + ->title(trans('ip.payment_recorded')) + ->success() + ->send(); + }), Action::make('download pdf') ->visible(fn () => auth()->user()?->can(Permission::DOWNLOAD_INVOICES->value)) ->label(trans('ip.download_pdf')) @@ -117,6 +197,10 @@ public static function configure(Table $table): Table Action::make('send email') ->visible(fn () => auth()->user()?->can(Permission::EMAIL_INVOICES->value)) ->label(trans('ip.send_email')) + ->disabled(fn (Invoice $record): bool => blank($record->customer?->customer_email)) + ->tooltip(fn (Invoice $record): ?string => blank($record->customer?->customer_email) + ? trans('ip.customer_has_no_email') + : null) ->action(function (Invoice $record): void { app(InvoiceService::class)->sendInvoiceEmail($record); // Optionally, show a notification @@ -127,7 +211,8 @@ public static function configure(Table $table): Table ->send(); }), DeleteAction::make('delete') - ->visible(fn () => auth()->user()?->can(Permission::DELETE_INVOICES->value)) + ->visible(fn (Invoice $record) => auth()->user()?->can(Permission::DELETE_INVOICES->value) + && $record->invoice_status !== InvoiceStatus::PAID) ->action(function (Invoice $record, array $data) { try { app(InvoiceService::class)->deleteInvoice($record); diff --git a/Modules/Invoices/Services/InvoiceCopyService.php b/Modules/Invoices/Services/InvoiceCopyService.php index e0bef63e8..03068badf 100644 --- a/Modules/Invoices/Services/InvoiceCopyService.php +++ b/Modules/Invoices/Services/InvoiceCopyService.php @@ -2,4 +2,64 @@ namespace Modules\Invoices\Services; -class InvoiceCopyService {} +use Illuminate\Support\Facades\DB; +use Illuminate\Support\Str; +use Modules\Invoices\Enums\InvoiceStatus; +use Modules\Invoices\Models\Invoice; + +class InvoiceCopyService +{ + /** + * Duplicate an invoice as a new Draft, without payments or transaction history. + * The copy gets a fresh url_key and a null invoice_number (assigned on edit). + */ + public function copy(Invoice $invoice): Invoice + { + return DB::transaction(function () use ($invoice) { + $copy = Invoice::query()->create([ + 'customer_id' => $invoice->customer_id, + 'numbering_id' => $invoice->numbering_id, + 'user_id' => auth()->id(), + 'invoice_number' => null, + 'invoice_status' => InvoiceStatus::DRAFT, + 'invoice_sign' => $invoice->invoice_sign ?? '1', + 'invoiced_at' => now(), + 'invoice_due_at' => now()->addDays(30), + 'invoice_discount_amount' => $invoice->invoice_discount_amount ?? 0, + 'invoice_discount_percent' => $invoice->invoice_discount_percent ?? 0, + 'item_tax_total' => $invoice->item_tax_total ?? 0, + 'invoice_item_subtotal' => $invoice->invoice_item_subtotal ?? 0, + 'invoice_tax_total' => $invoice->invoice_tax_total ?? 0, + 'invoice_total' => $invoice->invoice_total ?? 0, + 'url_key' => Str::random(32), + 'is_read_only' => false, + 'template' => $invoice->template, + 'summary' => $invoice->summary, + 'terms' => $invoice->terms, + 'footer' => $invoice->footer, + ]); + + foreach ($invoice->invoiceItems as $item) { + $copy->invoiceItems()->create([ + 'product_id' => $item->product_id, + 'product_unit_id' => $item->product_unit_id ?? null, + 'item_name' => $item->item_name, + 'quantity' => $item->quantity, + 'price' => $item->price, + 'discount' => $item->discount ?? 0, + 'subtotal' => $item->subtotal, + 'tax_1' => $item->tax_1 ?? 0, + 'tax_2' => $item->tax_2 ?? 0, + 'tax_total' => ($item->tax_1 ?? 0) + ($item->tax_2 ?? 0), + 'total' => $item->total ?? 0, + 'description' => $item->description, + 'tax_rate_id' => $item->tax_rate_id, + 'tax_rate_2_id' => $item->tax_rate_2_id, + 'display_order' => $item->display_order, + ]); + } + + return $copy; + }); + } +} diff --git a/Modules/Invoices/Tests/Feature/InvoiceListActionsTest.php b/Modules/Invoices/Tests/Feature/InvoiceListActionsTest.php new file mode 100644 index 000000000..6a8e8bbb1 --- /dev/null +++ b/Modules/Invoices/Tests/Feature/InvoiceListActionsTest.php @@ -0,0 +1,294 @@ +customer = Relation::factory()->for($this->company)->customer()->create(); + + $this->numbering = Numbering::factory() + ->for($this->company) + ->state(['type' => NumberingType::INVOICE->value]) + ->create(); + } + + #[Test] + #[Group('crud')] + public function it_copies_an_invoice_as_a_draft_duplicate_with_items(): void + { + /* Arrange */ + $invoice = $this->makeInvoice([ + 'invoice_status' => InvoiceStatus::SENT->value, + 'invoice_number' => 'INV-COPY-01', + 'invoice_total' => 500, + ]); + $itemCount = $invoice->invoiceItems()->count(); + + /* Act */ + $component = $this->listInvoices() + ->assertActionVisible(TestAction::make('copy')->table($invoice)) + ->callAction(TestAction::make('copy')->table($invoice)); + + /* Assert */ + $component->assertHasNoErrors(); + + $copy = Invoice::query() + ->where('id', '!=', $invoice->id) + ->orderByDesc('id') + ->first(); + + $this->assertNotNull($copy); + $this->assertNull($copy->invoice_number); + $this->assertSame(InvoiceStatus::DRAFT, $copy->invoice_status); + $this->assertSame($invoice->customer_id, $copy->customer_id); + $this->assertSame($invoice->numbering_id, $copy->numbering_id); + $this->assertSame('2026-01-01', $copy->invoiced_at->toDateString()); + $this->assertSame('2026-01-31', $copy->invoice_due_at->toDateString()); + $this->assertSame($itemCount, $copy->invoiceItems()->count()); + $this->assertSame(0, $copy->payments()->count()); + $this->assertNotSame($invoice->url_key, $copy->url_key); + } + + #[Test] + #[Group('crud')] + public function it_shows_enter_payment_only_for_open_invoice_statuses(): void + { + /* Arrange */ + $draft = $this->makeInvoice(['invoice_status' => InvoiceStatus::DRAFT->value]); + $sent = $this->makeInvoice(['invoice_status' => InvoiceStatus::SENT->value]); + $viewed = $this->makeInvoice(['invoice_status' => InvoiceStatus::VIEWED->value]); + $partial = $this->makeInvoice(['invoice_status' => InvoiceStatus::PARTIALLY_PAID->value]); + $overdue = $this->makeInvoice(['invoice_status' => InvoiceStatus::OVERDUE->value]); + $paid = $this->makeInvoice(['invoice_status' => InvoiceStatus::PAID->value]); + + /* Act */ + $component = $this->listInvoices(); + + /* Assert */ + $component + ->assertSuccessful() + ->assertActionVisible(TestAction::make('enter_payment')->table($sent)) + ->assertActionVisible(TestAction::make('enter_payment')->table($viewed)) + ->assertActionVisible(TestAction::make('enter_payment')->table($partial)) + ->assertActionVisible(TestAction::make('enter_payment')->table($overdue)) + ->assertActionHidden(TestAction::make('enter_payment')->table($draft)) + ->assertActionHidden(TestAction::make('enter_payment')->table($paid)); + } + + #[Test] + #[Group('crud')] + public function it_prefills_the_enter_payment_form_with_the_open_balance(): void + { + /* Arrange */ + $invoice = $this->makeInvoice([ + 'invoice_status' => InvoiceStatus::PARTIALLY_PAID->value, + 'invoice_total' => 100, + ]); + + Payment::factory()->for($this->company)->create([ + 'invoice_id' => $invoice->id, + 'customer_id' => $this->customer->id, + 'payment_amount' => 40, + 'payment_status' => PaymentStatus::COMPLETED->value, + ]); + + /* Act */ + $component = $this->listInvoices() + ->mountAction(TestAction::make('enter_payment')->table($invoice)); + + /* Assert */ + $component->assertActionDataSet([ + 'payment_amount' => 60.0, + 'paid_at' => '2026-01-01', + ]); + } + + #[Test] + #[Group('crud')] + public function it_records_a_full_payment_and_marks_the_invoice_paid(): void + { + /* Arrange */ + $invoice = $this->makeInvoice([ + 'invoice_status' => InvoiceStatus::SENT->value, + 'invoice_total' => 250, + ]); + + /* Act */ + $component = $this->listInvoices()->callAction( + TestAction::make('enter_payment')->table($invoice), + [ + 'payment_amount' => 250, + 'paid_at' => '2026-01-01', + 'payment_method' => PaymentMethod::BANK_TRANSFER->value, + ] + ); + + /* Assert */ + $component->assertHasNoErrors(); + + $payment = Payment::query()->where('invoice_id', $invoice->id)->first(); + $this->assertNotNull($payment); + $this->assertSame(250.0, (float) $payment->payment_amount); + $this->assertSame($this->customer->id, $payment->customer_id); + $this->assertSame(PaymentMethod::BANK_TRANSFER, $payment->payment_method); + + $this->assertSame(InvoiceStatus::PAID, $invoice->refresh()->invoice_status); + } + + #[Test] + #[Group('crud')] + public function it_records_a_partial_payment_and_marks_the_invoice_partially_paid(): void + { + /* Arrange */ + $invoice = $this->makeInvoice([ + 'invoice_status' => InvoiceStatus::SENT->value, + 'invoice_total' => 200, + ]); + + /* Act */ + $component = $this->listInvoices()->callAction( + TestAction::make('enter_payment')->table($invoice), + [ + 'payment_amount' => 50, + 'paid_at' => '2026-01-01', + 'payment_method' => PaymentMethod::CASH->value, + ] + ); + + /* Assert */ + $component->assertHasNoErrors(); + + $this->assertSame(1, Payment::query()->where('invoice_id', $invoice->id)->count()); + $this->assertSame(InvoiceStatus::PARTIALLY_PAID, $invoice->refresh()->invoice_status); + } + + #[Test] + #[Group('crud')] + public function it_disables_the_email_action_when_the_customer_has_no_email(): void + { + /* Arrange */ + $withoutEmail = Relation::factory()->for($this->company)->customer()->create(); + Communication::query() + ->where('communicationable_type', Contact::class) + ->whereIn('communicationable_id', $withoutEmail->contacts()->pluck('id')) + ->delete(); + + $withEmail = Relation::factory()->for($this->company)->customer()->create(); + $withEmail->primaryContact->communications()->create([ + 'company_id' => $this->company->id, + 'is_primary' => true, + 'communication_type' => CommunicationType::EMAIL->value, + 'communication_value' => 'billing@example.com', + ]); + + $invoiceWithoutEmail = $this->makeInvoice([ + 'customer_id' => $withoutEmail->id, + 'invoice_status' => InvoiceStatus::SENT->value, + ]); + $invoiceWithEmail = $this->makeInvoice([ + 'customer_id' => $withEmail->id, + 'invoice_status' => InvoiceStatus::SENT->value, + ]); + + /* Act */ + $component = $this->listInvoices(); + + /* Assert */ + $component + ->assertSuccessful() + ->assertActionDisabled(TestAction::make('send email')->table($invoiceWithoutEmail)) + ->assertActionEnabled(TestAction::make('send email')->table($invoiceWithEmail)); + } + + #[Test] + #[Group('crud')] + public function it_hides_the_delete_action_for_paid_invoices(): void + { + /* Arrange */ + $draft = $this->makeInvoice(['invoice_status' => InvoiceStatus::DRAFT->value]); + $paid = $this->makeInvoice(['invoice_status' => InvoiceStatus::PAID->value]); + + /* Act */ + $component = $this->listInvoices(); + + /* Assert */ + $component + ->assertSuccessful() + ->assertActionVisible(TestAction::make('delete')->table($draft)) + ->assertActionHidden(TestAction::make('delete')->table($paid)); + } + + #[Test] + #[Group('crud')] + public function it_filters_invoices_by_numbering_group(): void + { + /* Arrange */ + $otherNumbering = Numbering::factory() + ->for($this->company) + ->state(['type' => NumberingType::INVOICE->value]) + ->create(); + + $inDefaultNumbering = $this->makeInvoice(['invoice_status' => InvoiceStatus::SENT->value]); + $inOtherNumbering = $this->makeInvoice([ + 'invoice_status' => InvoiceStatus::SENT->value, + 'numbering_id' => $otherNumbering->id, + ]); + + /* Act + Assert */ + $this->listInvoices() + ->assertCanSeeTableRecords([$inDefaultNumbering, $inOtherNumbering]) + ->filterTable('numbering_id', $this->numbering->id) + ->assertCanSeeTableRecords([$inDefaultNumbering]) + ->assertCanNotSeeTableRecords([$inOtherNumbering]); + } + + protected function makeInvoice(array $attributes = []): Invoice + { + return Invoice::factory()->for($this->company)->create(array_merge([ + 'customer_id' => $this->customer->id, + 'numbering_id' => $this->numbering->id, + 'user_id' => $this->user->id, + 'is_read_only' => false, + ], $attributes)); + } + + protected function listInvoices() + { + return Livewire::actingAs($this->user) + ->test(ListInvoices::class, ['tenant' => Str::lower($this->company->search_code)]); + } +} 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/Payments/Services/PaymentService.php b/Modules/Payments/Services/PaymentService.php index 0596e12cb..39d37f567 100644 --- a/Modules/Payments/Services/PaymentService.php +++ b/Modules/Payments/Services/PaymentService.php @@ -6,6 +6,7 @@ use Illuminate\Support\Facades\DB; use Modules\Core\Services\BaseService; use Modules\Core\Support\NumberFormatter; +use Modules\Invoices\Enums\InvoiceStatus; use Modules\Invoices\Models\Invoice; use Modules\Payments\Enums\PaymentStatus; use Modules\Payments\Models\Payment; @@ -31,6 +32,41 @@ public function createPayment(array $data): Model return $payment; } + /** + * Record a payment against an invoice and keep the invoice status in sync: + * fully paid invoices become Paid, partly paid Sent/Viewed invoices become + * Partially Paid (Overdue invoices stay Overdue until settled in full). + */ + public function enterInvoicePayment(Invoice $invoice, array $data): Payment + { + return DB::transaction(function () use ($invoice, $data) { + /** @var Payment $payment */ + $payment = $this->createPayment([ + 'customer_id' => $invoice->customer_id, + 'invoice_id' => $invoice->id, + 'payment_method' => $data['payment_method'], + 'payment_status' => $data['payment_status'] ?? PaymentStatus::COMPLETED->value, + 'payment_amount' => $data['payment_amount'], + 'paid_at' => $data['paid_at'], + 'notes' => $data['notes'] ?? null, + ]); + + $this->syncInvoiceStatus($invoice); + + return $payment; + }); + } + + /** + * The open balance of an invoice: total minus the sum of its payments. + */ + public function amountOwed(Invoice $invoice): float + { + $paid = (float) $invoice->payments()->sum('payment_amount'); + + return max(round((float) $invoice->invoice_total - $paid, 4), 0.0); + } + public function updatePayment(Payment $payment, array $data): Payment { DB::beginTransaction(); @@ -62,6 +98,19 @@ public function deletePayment(Payment $payment): Payment return $payment; } + protected function syncInvoiceStatus(Invoice $invoice): void + { + if ($this->amountOwed($invoice) <= 0.0) { + $invoice->update(['invoice_status' => InvoiceStatus::PAID]); + + return; + } + + if (in_array($invoice->invoice_status, [InvoiceStatus::SENT, InvoiceStatus::VIEWED], true)) { + $invoice->update(['invoice_status' => InvoiceStatus::PARTIALLY_PAID]); + } + } + protected function preparePaymentData(array $data): array { $customerId = $data['customer_id'] ?? $this->getCustomerIdFromInvoice($data['invoice_id']); 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..b3c54f2fa 100644 --- a/composer.json +++ b/composer.json @@ -49,12 +49,14 @@ "psr-4": { "App\\": "app/", "Modules\\": "Modules/", - "Database\\Seeders\\": "database/seeders/" + "Database\\Seeders\\": "database/seeders/", + "Fable5\\": "automation/fable5/src/" } }, "autoload-dev": { "psr-4": { - "Tests\\": "tests/" + "Tests\\": "tests/", + "Fable5\\Tests\\": "automation/fable5/tests/" } }, "scripts": { 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..34e6380e8 100644 --- a/resources/lang/en/ip.php +++ b/resources/lang/en/ip.php @@ -18,6 +18,7 @@ 'coc_number' => 'Chamber of Commerce Number', 'company_name' => 'Customer Name', 'contact_information' => 'Contact Information', + 'customer_has_no_email' => 'This customer has no email address', 'customer_name' => 'Customer Name', 'delete_client' => 'Delete Client', 'delete_client_warning' => 'If you delete this client you will also delete any invoices, quotes and payments related to this client. Are you sure you want to permanently delete this client?', @@ -606,6 +607,7 @@ 'payment_method_paypal' => 'PayPal', 'payment_method_stripe' => 'Stripe', 'payment_methods' => 'Payment Methods', + 'payment_recorded' => 'Payment recorded', 'payment_reference' => 'Payment Reference', 'payment_status' => 'Payment Status', 'payments' => 'Payments', @@ -616,6 +618,7 @@ #region INVOICES 'copy_invoice' => 'Copy Invoice', + 'invoice_copied' => 'Invoice copied as draft', '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',