diff --git a/.gitignore b/.gitignore index 375301ed..bac5879a 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 42f190a8..c9bd2cce 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 452f1325..03d996fb 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 5ea2f767..a484e8a7 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 1be90460..768d61c5 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 09d7fc17..0bc12d87 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 e0bef63e..03068bad 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 00000000..6a8e8bbb --- /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 cd401fec..8d50d570 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 0596e12c..39d37f56 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 2fdfdce5..5b3e5baf 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 b3603f84..b3c54f2f 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 00000000..79f26c47 --- /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 e60cf585..34e6380e 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',