Andrew Wallo пре 10 месеци
родитељ
комит
88708d45a8

+ 27
- 0
app/Collections/Accounting/InvoiceCollection.php Прегледај датотеку

@@ -0,0 +1,27 @@
1
+<?php
2
+
3
+namespace App\Collections\Accounting;
4
+
5
+use App\Models\Accounting\Invoice;
6
+use App\Utilities\Currency\CurrencyAccessor;
7
+use App\Utilities\Currency\CurrencyConverter;
8
+use Illuminate\Database\Eloquent\Collection;
9
+
10
+class InvoiceCollection extends Collection
11
+{
12
+    public function sumMoneyInCents(string $column): int
13
+    {
14
+        return $this->reduce(static function ($carry, Invoice $invoice) use ($column) {
15
+            return $carry + $invoice->getRawOriginal($column);
16
+        }, 0);
17
+    }
18
+
19
+    public function sumMoneyFormattedSimple(string $column, ?string $currency = null): string
20
+    {
21
+        $currency ??= CurrencyAccessor::getDefaultCurrency();
22
+
23
+        $totalCents = $this->sumMoneyInCents($column);
24
+
25
+        return CurrencyConverter::convertCentsToFormatSimple($totalCents, $currency);
26
+    }
27
+}

+ 217
- 41
app/Filament/Company/Resources/Sales/InvoiceResource.php Прегледај датотеку

@@ -2,13 +2,13 @@
2 2
 
3 3
 namespace App\Filament\Company\Resources\Sales;
4 4
 
5
+use App\Collections\Accounting\InvoiceCollection;
5 6
 use App\Enums\Accounting\InvoiceStatus;
6
-use App\Enums\Accounting\JournalEntryType;
7 7
 use App\Enums\Accounting\PaymentMethod;
8
-use App\Enums\Accounting\TransactionType;
9 8
 use App\Filament\Company\Resources\Sales\InvoiceResource\Pages;
10 9
 use App\Filament\Company\Resources\Sales\InvoiceResource\RelationManagers;
11
-use App\Models\Accounting\Account;
10
+use App\Filament\Tables\Actions\ReplicateBulkAction;
11
+use App\Filament\Tables\Filters\DateRangeFilter;
12 12
 use App\Models\Accounting\Adjustment;
13 13
 use App\Models\Accounting\DocumentLineItem;
14 14
 use App\Models\Accounting\Invoice;
@@ -23,11 +23,13 @@ use Closure;
23 23
 use Filament\Forms;
24 24
 use Filament\Forms\Components\FileUpload;
25 25
 use Filament\Forms\Form;
26
+use Filament\Notifications\Notification;
26 27
 use Filament\Resources\Resource;
27 28
 use Filament\Support\Enums\Alignment;
28 29
 use Filament\Support\Enums\MaxWidth;
29 30
 use Filament\Tables;
30 31
 use Filament\Tables\Table;
32
+use Illuminate\Database\Eloquent\Collection;
31 33
 use Illuminate\Database\Eloquent\Model;
32 34
 use Illuminate\Support\Carbon;
33 35
 use Illuminate\Support\Facades\Auth;
@@ -236,7 +238,7 @@ class InvoiceResource extends Resource
236 238
                                     ->live()
237 239
                                     ->afterStateUpdated(function (Forms\Set $set, Forms\Get $get, $state) {
238 240
                                         $offeringId = $state;
239
-                                        $offeringRecord = Offering::with('salesTaxes')->find($offeringId);
241
+                                        $offeringRecord = Offering::with(['salesTaxes', 'salesDiscounts'])->find($offeringId);
240 242
 
241 243
                                         if ($offeringRecord) {
242 244
                                             $set('description', $offeringRecord->description);
@@ -446,8 +448,24 @@ class InvoiceResource extends Resource
446 448
                     ->currency(),
447 449
             ])
448 450
             ->filters([
449
-                //
450
-            ])
451
+                Tables\Filters\SelectFilter::make('status')
452
+                    ->options(InvoiceStatus::class)
453
+                    ->native(false),
454
+                Tables\Filters\SelectFilter::make('client')
455
+                    ->relationship('client', 'name')
456
+                    ->searchable()
457
+                    ->preload(),
458
+                DateRangeFilter::make('date')
459
+                    ->fromLabel('From Date')
460
+                    ->untilLabel('Until Date')
461
+                    ->indicatorLabel('Date Range'),
462
+            ], layout: Tables\Enums\FiltersLayout::Modal)
463
+            ->filtersFormWidth(MaxWidth::Small)
464
+            ->filtersTriggerAction(
465
+                fn (Tables\Actions\Action $action) => $action
466
+                    ->label('Filter')
467
+                    ->slideOver(),
468
+            )
451 469
             ->actions([
452 470
                 Tables\Actions\ActionGroup::make([
453 471
                     Tables\Actions\EditAction::make(),
@@ -497,41 +515,7 @@ class InvoiceResource extends Resource
497 515
                         ->databaseTransaction()
498 516
                         ->successNotificationTitle('Invoice Approved')
499 517
                         ->action(function (Invoice $record, Tables\Actions\Action $action) {
500
-                            $transaction = $record->transactions()->create([
501
-                                'type' => TransactionType::Journal,
502
-                                'posted_at' => now(),
503
-                                'amount' => $record->total,
504
-                                'description' => 'Invoice Approval for Invoice #' . $record->invoice_number,
505
-                            ]);
506
-
507
-                            $transaction->journalEntries()->create([
508
-                                'type' => JournalEntryType::Debit,
509
-                                'account_id' => Account::getAccountsReceivableAccount()->id,
510
-                                'amount' => $record->total,
511
-                                'description' => $transaction->description,
512
-                            ]);
513
-
514
-                            foreach ($record->lineItems as $lineItem) {
515
-                                $transaction->journalEntries()->create([
516
-                                    'type' => JournalEntryType::Credit,
517
-                                    'account_id' => $lineItem->offering->income_account_id,
518
-                                    'amount' => $lineItem->subtotal,
519
-                                    'description' => $transaction->description,
520
-                                ]);
521
-
522
-                                foreach ($lineItem->adjustments as $adjustment) {
523
-                                    $transaction->journalEntries()->create([
524
-                                        'type' => $adjustment->category->isDiscount() ? JournalEntryType::Debit : JournalEntryType::Credit,
525
-                                        'account_id' => $adjustment->account_id,
526
-                                        'amount' => $lineItem->calculateAdjustmentTotal($adjustment)->getAmount(),
527
-                                        'description' => $transaction->description,
528
-                                    ]);
529
-                                }
530
-                            }
531
-
532
-                            $record->updateQuietly([
533
-                                'status' => InvoiceStatus::Unsent,
534
-                            ]);
518
+                            $record->approveDraft();
535 519
 
536 520
                             $action->success();
537 521
                         }),
@@ -631,6 +615,198 @@ class InvoiceResource extends Resource
631 615
             ->bulkActions([
632 616
                 Tables\Actions\BulkActionGroup::make([
633 617
                     Tables\Actions\DeleteBulkAction::make(),
618
+                    ReplicateBulkAction::make()
619
+                        ->label('Replicate')
620
+                        ->modalWidth(MaxWidth::Large)
621
+                        ->modalDescription('Replicating invoices will also replicate their line items. Are you sure you want to proceed?')
622
+                        ->successNotificationTitle('Invoices Replicated Successfully')
623
+                        ->failureNotificationTitle('Failed to Replicate Invoices')
624
+                        ->databaseTransaction()
625
+                        ->deselectRecordsAfterCompletion()
626
+                        ->excludeAttributes([
627
+                            'status',
628
+                            'amount_paid',
629
+                            'amount_due',
630
+                            'created_by',
631
+                            'updated_by',
632
+                            'created_at',
633
+                            'updated_at',
634
+                            'invoice_number',
635
+                            'date',
636
+                            'due_date',
637
+                        ])
638
+                        ->beforeReplicaSaved(function (Invoice $replica) {
639
+                            $replica->status = InvoiceStatus::Draft;
640
+                            $replica->invoice_number = Invoice::getNextDocumentNumber();
641
+                            $replica->date = now();
642
+                            $replica->due_date = now()->addDays($replica->company->defaultInvoice->payment_terms->getDays());
643
+                        })
644
+                        ->withReplicatedRelationships(['lineItems'])
645
+                        ->withExcludedRelationshipAttributes('lineItems', [
646
+                            'subtotal',
647
+                            'total',
648
+                            'created_by',
649
+                            'updated_by',
650
+                            'created_at',
651
+                            'updated_at',
652
+                        ]),
653
+                    Tables\Actions\BulkAction::make('approveDrafts')
654
+                        ->label('Approve')
655
+                        ->icon('heroicon-o-check-circle')
656
+                        ->databaseTransaction()
657
+                        ->successNotificationTitle('Invoices Approved')
658
+                        ->failureNotificationTitle('Failed to Approve Invoices')
659
+                        ->before(function (Collection $records, Tables\Actions\BulkAction $action) {
660
+                            $containsNonDrafts = $records->contains(fn (Invoice $record) => ! $record->isDraft());
661
+
662
+                            if ($containsNonDrafts) {
663
+                                Notification::make()
664
+                                    ->title('Approval Failed')
665
+                                    ->body('Only draft invoices can be approved. Please adjust your selection and try again.')
666
+                                    ->persistent()
667
+                                    ->danger()
668
+                                    ->send();
669
+
670
+                                $action->cancel(true);
671
+                            }
672
+                        })
673
+                        ->action(function (Collection $records, Tables\Actions\BulkAction $action) {
674
+                            $records->each(function (Invoice $record) {
675
+                                $record->approveDraft();
676
+                            });
677
+
678
+                            $action->success();
679
+                        }),
680
+                    Tables\Actions\BulkAction::make('markAsSent')
681
+                        ->label('Mark as Sent')
682
+                        ->icon('heroicon-o-paper-airplane')
683
+                        ->databaseTransaction()
684
+                        ->successNotificationTitle('Invoices Sent')
685
+                        ->failureNotificationTitle('Failed to Mark Invoices as Sent')
686
+                        ->before(function (Collection $records, Tables\Actions\BulkAction $action) {
687
+                            $doesntContainUnsent = $records->contains(fn (Invoice $record) => $record->status !== InvoiceStatus::Unsent);
688
+
689
+                            if ($doesntContainUnsent) {
690
+                                Notification::make()
691
+                                    ->title('Sending Failed')
692
+                                    ->body('Only unsent invoices can be marked as sent. Please adjust your selection and try again.')
693
+                                    ->persistent()
694
+                                    ->danger()
695
+                                    ->send();
696
+
697
+                                $action->cancel(true);
698
+                            }
699
+                        })
700
+                        ->action(function (Collection $records, Tables\Actions\BulkAction $action) {
701
+                            $records->each(function (Invoice $record) {
702
+                                $record->updateQuietly([
703
+                                    'status' => InvoiceStatus::Sent,
704
+                                ]);
705
+                            });
706
+
707
+                            $action->success();
708
+                        }),
709
+                    Tables\Actions\BulkAction::make('recordPayments')
710
+                        ->label('Record Payments')
711
+                        ->icon('heroicon-o-credit-card')
712
+                        ->stickyModalHeader()
713
+                        ->stickyModalFooter()
714
+                        ->modalFooterActionsAlignment(Alignment::End)
715
+                        ->modalWidth(MaxWidth::TwoExtraLarge)
716
+                        ->databaseTransaction()
717
+                        ->successNotificationTitle('Payments Recorded')
718
+                        ->failureNotificationTitle('Failed to Record Payments')
719
+                        ->deselectRecordsAfterCompletion()
720
+                        ->beforeFormFilled(function (Collection $records, Tables\Actions\BulkAction $action) {
721
+                            $cantRecordPayments = $records->contains(fn (Invoice $record) => ! $record->canBulkRecordPayment());
722
+
723
+                            if ($cantRecordPayments) {
724
+                                Notification::make()
725
+                                    ->title('Payment Recording Failed')
726
+                                    ->body('Invoices that are either draft, paid, overpaid, or voided cannot be processed through bulk payments. Please adjust your selection and try again.')
727
+                                    ->persistent()
728
+                                    ->danger()
729
+                                    ->send();
730
+
731
+                                $action->cancel(true);
732
+                            }
733
+                        })
734
+                        ->mountUsing(function (InvoiceCollection $records, Form $form) {
735
+                            $totalAmountDue = $records->sumMoneyFormattedSimple('amount_due');
736
+
737
+                            $form->fill([
738
+                                'posted_at' => now(),
739
+                                'amount' => $totalAmountDue,
740
+                            ]);
741
+                        })
742
+                        ->form([
743
+                            Forms\Components\DatePicker::make('posted_at')
744
+                                ->label('Date'),
745
+                            Forms\Components\TextInput::make('amount')
746
+                                ->label('Amount')
747
+                                ->required()
748
+                                ->money()
749
+                                ->rules([
750
+                                    static fn (): Closure => static function (string $attribute, $value, Closure $fail) {
751
+                                        if (! CurrencyConverter::isValidAmount($value)) {
752
+                                            $fail('Please enter a valid amount');
753
+                                        }
754
+                                    },
755
+                                ]),
756
+                            Forms\Components\Select::make('payment_method')
757
+                                ->label('Payment Method')
758
+                                ->required()
759
+                                ->options(PaymentMethod::class),
760
+                            Forms\Components\Select::make('bank_account_id')
761
+                                ->label('Account')
762
+                                ->required()
763
+                                ->options(BankAccount::query()
764
+                                    ->get()
765
+                                    ->pluck('account.name', 'id'))
766
+                                ->searchable(),
767
+                            Forms\Components\Textarea::make('notes')
768
+                                ->label('Notes'),
769
+                        ])
770
+                        ->before(function (InvoiceCollection $records, Tables\Actions\BulkAction $action, array $data) {
771
+                            $totalPaymentAmount = CurrencyConverter::convertToCents($data['amount']);
772
+                            $totalAmountDue = $records->sumMoneyInCents('amount_due');
773
+
774
+                            if ($totalPaymentAmount > $totalAmountDue) {
775
+                                $formattedTotalAmountDue = CurrencyConverter::formatCentsToMoney($totalAmountDue);
776
+
777
+                                Notification::make()
778
+                                    ->title('Excess Payment Amount')
779
+                                    ->body("The payment amount exceeds the total amount due of {$formattedTotalAmountDue}. Please adjust the payment amount and try again.")
780
+                                    ->persistent()
781
+                                    ->warning()
782
+                                    ->send();
783
+
784
+                                $action->halt(true);
785
+                            }
786
+                        })
787
+                        ->action(function (InvoiceCollection $records, Tables\Actions\BulkAction $action, array $data) {
788
+                            $totalPaymentAmount = CurrencyConverter::convertToCents($data['amount']);
789
+
790
+                            $remainingAmount = $totalPaymentAmount;
791
+
792
+                            $records->each(function (Invoice $record) use (&$remainingAmount, $data) {
793
+                                $amountDue = $record->getRawOriginal('amount_due');
794
+
795
+                                if ($amountDue <= 0 || $remainingAmount <= 0) {
796
+                                    return;
797
+                                }
798
+
799
+                                $paymentAmount = min($amountDue, $remainingAmount);
800
+
801
+                                $data['amount'] = CurrencyConverter::convertCentsToFormatSimple($paymentAmount);
802
+
803
+                                $record->recordPayment($data);
804
+
805
+                                $remainingAmount -= $paymentAmount;
806
+                            });
807
+
808
+                            $action->success();
809
+                        }),
634 810
                 ]),
635 811
             ]);
636 812
     }

+ 3
- 0
app/Filament/Company/Resources/Sales/InvoiceResource/Pages/ViewInvoice.php Прегледај датотеку

@@ -38,6 +38,9 @@ class ViewInvoice extends ViewRecord
38 38
                             ->color('primary')
39 39
                             ->weight(FontWeight::SemiBold)
40 40
                             ->url(fn (Invoice $record) => ClientResource::getUrl('edit', ['record' => $record->client_id])),
41
+                        TextEntry::make('total')
42
+                            ->label('Total')
43
+                            ->money(),
41 44
                         TextEntry::make('amount_due')
42 45
                             ->label('Amount Due')
43 46
                             ->money(),

+ 31
- 5
app/Filament/Tables/Actions/ReplicateBulkAction.php Прегледај датотеку

@@ -11,6 +11,7 @@ use Illuminate\Database\Eloquent\Model;
11 11
 use Illuminate\Database\Eloquent\Relations\BelongsToMany;
12 12
 use Illuminate\Database\Eloquent\Relations\HasMany;
13 13
 use Illuminate\Database\Eloquent\Relations\HasOne;
14
+use Illuminate\Database\Eloquent\Relations\MorphMany;
14 15
 
15 16
 class ReplicateBulkAction extends BulkAction implements ReplicatesRecords
16 17
 {
@@ -20,6 +21,8 @@ class ReplicateBulkAction extends BulkAction implements ReplicatesRecords
20 21
 
21 22
     protected array $relationshipsToReplicate = [];
22 23
 
24
+    protected array | Closure | null $excludedAttributesPerRelationship = null;
25
+
23 26
     public static function getDefaultName(): ?string
24 27
     {
25 28
         return 'replicate';
@@ -48,8 +51,6 @@ class ReplicateBulkAction extends BulkAction implements ReplicatesRecords
48 51
                 $records->each(function (Model $record) {
49 52
                     $this->replica = $record->replicate($this->getExcludedAttributes());
50 53
 
51
-                    $this->replica->fill($record->attributesToArray());
52
-
53 54
                     $this->callBeforeReplicaSaved();
54 55
 
55 56
                     $this->replica->save();
@@ -73,17 +74,30 @@ class ReplicateBulkAction extends BulkAction implements ReplicatesRecords
73 74
         foreach ($this->relationshipsToReplicate as $relationship) {
74 75
             $relation = $original->$relationship();
75 76
 
77
+            $excludedAttributes = $this->excludedAttributesPerRelationship[$relationship] ?? [];
78
+
76 79
             if ($relation instanceof BelongsToMany) {
77 80
                 $replica->$relationship()->sync($relation->pluck($relation->getRelated()->getKeyName()));
78 81
             } elseif ($relation instanceof HasMany) {
79
-                $relation->each(function (Model $related) use ($replica, $relationship) {
80
-                    $relatedReplica = $related->replicate($this->getExcludedAttributes());
82
+                $relation->each(function (Model $related) use ($excludedAttributes, $replica, $relationship) {
83
+                    $relatedReplica = $related->replicate($excludedAttributes);
81 84
                     $relatedReplica->{$replica->$relationship()->getForeignKeyName()} = $replica->getKey();
82 85
                     $relatedReplica->save();
83 86
                 });
87
+            } elseif ($relation instanceof MorphMany) {
88
+                $relation->each(function (Model $related) use ($excludedAttributes, $relation, $replica) {
89
+                    $relatedReplica = $related->replicate($excludedAttributes);
90
+                    $relatedReplica->{$relation->getForeignKeyName()} = $replica->getKey();
91
+                    $relatedReplica->{$relation->getMorphType()} = $replica->getMorphClass();
92
+                    $relatedReplica->save();
93
+
94
+                    if (method_exists($related, 'adjustments')) {
95
+                        $relatedReplica->adjustments()->sync($related->adjustments->pluck('id'));
96
+                    }
97
+                });
84 98
             } elseif ($relation instanceof HasOne && $relation->exists()) {
85 99
                 $related = $relation->first();
86
-                $relatedReplica = $related->replicate($this->getExcludedAttributes());
100
+                $relatedReplica = $related->replicate($excludedAttributes);
87 101
                 $relatedReplica->{$replica->$relationship()->getForeignKeyName()} = $replica->getKey();
88 102
                 $relatedReplica->save();
89 103
             }
@@ -97,6 +111,18 @@ class ReplicateBulkAction extends BulkAction implements ReplicatesRecords
97 111
         return $this;
98 112
     }
99 113
 
114
+    public function withExcludedRelationshipAttributes(string $relationship, array | Closure | null $attributes): static
115
+    {
116
+        $this->excludedAttributesPerRelationship[$relationship] = $attributes;
117
+
118
+        return $this;
119
+    }
120
+
121
+    public function getExcludedRelationshipAttributes(): ?array
122
+    {
123
+        return $this->evaluate($this->excludedAttributesPerRelationship);
124
+    }
125
+
100 126
     public function afterReplicaSaved(Closure $callback): static
101 127
     {
102 128
         $this->afterReplicaSaved = $callback;

+ 172
- 0
app/Filament/Tables/Filters/DateRangeFilter.php Прегледај датотеку

@@ -0,0 +1,172 @@
1
+<?php
2
+
3
+namespace App\Filament\Tables\Filters;
4
+
5
+use Filament\Forms\Components\DatePicker;
6
+use Filament\Forms\Get;
7
+use Filament\Forms\Set;
8
+use Filament\Tables\Filters\Filter;
9
+use Filament\Tables\Filters\Indicator;
10
+use Illuminate\Database\Eloquent\Builder;
11
+use Illuminate\Support\Carbon;
12
+use Illuminate\Support\Str;
13
+
14
+class DateRangeFilter extends Filter
15
+{
16
+    protected string $fromLabel = 'From';
17
+
18
+    protected string $untilLabel = 'Until';
19
+
20
+    protected ?string $indicatorLabel = null;
21
+
22
+    protected ?string $defaultFromDate = null;
23
+
24
+    protected ?string $defaultUntilDate = null;
25
+
26
+    protected ?string $fromColumn = null;
27
+
28
+    protected ?string $untilColumn = null;
29
+
30
+    protected function setUp(): void
31
+    {
32
+        parent::setUp();
33
+
34
+        $this->form([
35
+            DatePicker::make('from')
36
+                ->label(fn () => $this->fromLabel)
37
+                ->live()
38
+                ->default(fn () => $this->defaultFromDate)
39
+                ->maxDate(function (Get $get) {
40
+                    return $get('until');
41
+                })
42
+                ->afterStateUpdated(function (Set $set, $state) {
43
+                    if (! $state) {
44
+                        $set('until', null);
45
+                    }
46
+                }),
47
+            DatePicker::make('until')
48
+                ->label(fn () => $this->untilLabel)
49
+                ->live()
50
+                ->default(fn () => $this->defaultUntilDate)
51
+                ->minDate(function (Get $get) {
52
+                    return $get('from');
53
+                }),
54
+        ]);
55
+
56
+        $this->query(function (Builder $query, array $data): Builder {
57
+            $fromColumn = $this->fromColumn ?? $this->getName();
58
+            $untilColumn = $this->untilColumn ?? $this->getName();
59
+
60
+            $fromDate = filled($data['from'] ?? null)
61
+                ? Carbon::parse($data['from'])
62
+                : null;
63
+
64
+            $untilDate = filled($data['until'] ?? null)
65
+                ? Carbon::parse($data['until'])
66
+                : null;
67
+
68
+            if (! $fromDate && ! $untilDate) {
69
+                return $query;
70
+            }
71
+
72
+            return $this->applyDateFilter($query, $fromDate, $untilDate, $fromColumn, $untilColumn);
73
+        });
74
+
75
+        $this->indicateUsing(function (array $data): array {
76
+            $indicators = [];
77
+
78
+            $fromDateFormatted = filled($data['from'] ?? null)
79
+                ? Carbon::parse($data['from'])->toDefaultDateFormat()
80
+                : null;
81
+
82
+            $untilDateFormatted = filled($data['until'] ?? null)
83
+                ? Carbon::parse($data['until'])->toDefaultDateFormat()
84
+                : null;
85
+
86
+            if ($fromDateFormatted && $untilDateFormatted) {
87
+                $indicators[] = Indicator::make($this->getIndicatorLabel() . ': ' . $fromDateFormatted . ' - ' . $untilDateFormatted);
88
+            } else {
89
+                if ($fromDateFormatted) {
90
+                    $indicators[] = Indicator::make($this->fromLabel . ': ' . $fromDateFormatted)
91
+                        ->removeField('from');
92
+                }
93
+
94
+                if ($untilDateFormatted) {
95
+                    $indicators[] = Indicator::make($this->untilLabel . ': ' . $untilDateFormatted)
96
+                        ->removeField('until');
97
+                }
98
+            }
99
+
100
+            return $indicators;
101
+        });
102
+    }
103
+
104
+    protected function applyDateFilter(Builder $query, ?Carbon $fromDate, ?Carbon $untilDate, string $fromColumn, string $untilColumn): Builder
105
+    {
106
+        return $query
107
+            ->when($fromDate && ! $untilDate, function (Builder $query) use ($fromColumn, $fromDate) {
108
+                return $query->where($fromColumn, '>=', $fromDate);
109
+            })
110
+            ->when($fromDate && $untilDate, function (Builder $query) use ($fromColumn, $fromDate, $untilColumn, $untilDate) {
111
+                return $query->where($fromColumn, '>=', $fromDate)
112
+                    ->where($untilColumn, '<=', $untilDate);
113
+            })
114
+            ->when(! $fromDate && $untilDate, function (Builder $query) use ($untilColumn, $untilDate) {
115
+                return $query->where($untilColumn, '<=', $untilDate);
116
+            });
117
+    }
118
+
119
+    public function fromLabel(string $label): static
120
+    {
121
+        $this->fromLabel = $label;
122
+
123
+        return $this;
124
+    }
125
+
126
+    public function untilLabel(string $label): static
127
+    {
128
+        $this->untilLabel = $label;
129
+
130
+        return $this;
131
+    }
132
+
133
+    public function indicatorLabel(string $label): static
134
+    {
135
+        $this->indicatorLabel = $label;
136
+
137
+        return $this;
138
+    }
139
+
140
+    public function getIndicatorLabel(): string
141
+    {
142
+        return $this->indicatorLabel ?? Str::headline($this->getName());
143
+    }
144
+
145
+    public function defaultFromDate(string $date): static
146
+    {
147
+        $this->defaultFromDate = $date;
148
+
149
+        return $this;
150
+    }
151
+
152
+    public function defaultUntilDate(string $date): static
153
+    {
154
+        $this->defaultUntilDate = $date;
155
+
156
+        return $this;
157
+    }
158
+
159
+    public function fromColumn(string $column): static
160
+    {
161
+        $this->fromColumn = $column;
162
+
163
+        return $this;
164
+    }
165
+
166
+    public function untilColumn(string $column): static
167
+    {
168
+        $this->untilColumn = $column;
169
+
170
+        return $this;
171
+    }
172
+}

+ 62
- 0
app/Models/Accounting/Invoice.php Прегледај датотеку

@@ -3,12 +3,15 @@
3 3
 namespace App\Models\Accounting;
4 4
 
5 5
 use App\Casts\MoneyCast;
6
+use App\Collections\Accounting\InvoiceCollection;
6 7
 use App\Concerns\Blamable;
7 8
 use App\Concerns\CompanyOwned;
8 9
 use App\Enums\Accounting\InvoiceStatus;
10
+use App\Enums\Accounting\JournalEntryType;
9 11
 use App\Enums\Accounting\TransactionType;
10 12
 use App\Models\Common\Client;
11 13
 use App\Observers\InvoiceObserver;
14
+use Illuminate\Database\Eloquent\Attributes\CollectedBy;
12 15
 use Illuminate\Database\Eloquent\Attributes\ObservedBy;
13 16
 use Illuminate\Database\Eloquent\Factories\HasFactory;
14 17
 use Illuminate\Database\Eloquent\Model;
@@ -17,6 +20,7 @@ use Illuminate\Database\Eloquent\Relations\MorphMany;
17 20
 use Illuminate\Database\Eloquent\Relations\MorphOne;
18 21
 
19 22
 #[ObservedBy(InvoiceObserver::class)]
23
+#[CollectedBy(InvoiceCollection::class)]
20 24
 class Invoice extends Model
21 25
 {
22 26
     use Blamable;
@@ -110,6 +114,16 @@ class Invoice extends Model
110 114
         ]);
111 115
     }
112 116
 
117
+    public function canBulkRecordPayment(): bool
118
+    {
119
+        return ! in_array($this->status, [
120
+            InvoiceStatus::Draft,
121
+            InvoiceStatus::Paid,
122
+            InvoiceStatus::Void,
123
+            InvoiceStatus::Overpaid,
124
+        ]);
125
+    }
126
+
113 127
     public static function getNextDocumentNumber(): string
114 128
     {
115 129
         $company = auth()->user()->currentCompany;
@@ -157,6 +171,7 @@ class Invoice extends Model
157 171
 
158 172
         // Create transaction
159 173
         $this->transactions()->create([
174
+            'company_id' => $this->company_id,
160 175
             'type' => $transactionType,
161 176
             'is_payment' => true,
162 177
             'posted_at' => $data['posted_at'],
@@ -168,4 +183,51 @@ class Invoice extends Model
168 183
             'notes' => $data['notes'] ?? null,
169 184
         ]);
170 185
     }
186
+
187
+    public function approveDraft(): void
188
+    {
189
+        if (! $this->isDraft()) {
190
+            throw new \RuntimeException('Invoice is not in draft status.');
191
+        }
192
+
193
+        $transaction = $this->transactions()->create([
194
+            'company_id' => $this->company_id,
195
+            'type' => TransactionType::Journal,
196
+            'posted_at' => now(),
197
+            'amount' => $this->total,
198
+            'description' => 'Invoice Approval for Invoice #' . $this->invoice_number,
199
+        ]);
200
+
201
+        $transaction->journalEntries()->create([
202
+            'company_id' => $this->company_id,
203
+            'type' => JournalEntryType::Debit,
204
+            'account_id' => Account::getAccountsReceivableAccount()->id,
205
+            'amount' => $this->total,
206
+            'description' => $transaction->description,
207
+        ]);
208
+
209
+        foreach ($this->lineItems as $lineItem) {
210
+            $transaction->journalEntries()->create([
211
+                'company_id' => $this->company_id,
212
+                'type' => JournalEntryType::Credit,
213
+                'account_id' => $lineItem->offering->income_account_id,
214
+                'amount' => $lineItem->subtotal,
215
+                'description' => $transaction->description,
216
+            ]);
217
+
218
+            foreach ($lineItem->adjustments as $adjustment) {
219
+                $transaction->journalEntries()->create([
220
+                    'company_id' => $this->company_id,
221
+                    'type' => $adjustment->category->isDiscount() ? JournalEntryType::Debit : JournalEntryType::Credit,
222
+                    'account_id' => $adjustment->account_id,
223
+                    'amount' => $lineItem->calculateAdjustmentTotal($adjustment)->getAmount(),
224
+                    'description' => $transaction->description,
225
+                ]);
226
+            }
227
+        }
228
+
229
+        $this->updateQuietly([
230
+            'status' => InvoiceStatus::Unsent,
231
+        ]);
232
+    }
171 233
 }

+ 8
- 8
app/Observers/InvoiceObserver.php Прегледај датотеку

@@ -26,6 +26,14 @@ class InvoiceObserver
26 26
     }
27 27
 
28 28
     public function deleting(Invoice $invoice): void
29
+    {
30
+        //
31
+    }
32
+
33
+    /**
34
+     * Handle the Invoice "deleted" event.
35
+     */
36
+    public function deleted(Invoice $invoice): void
29 37
     {
30 38
         DB::transaction(function () use ($invoice) {
31 39
             $invoice->lineItems()->each(function (DocumentLineItem $lineItem) {
@@ -38,14 +46,6 @@ class InvoiceObserver
38 46
         });
39 47
     }
40 48
 
41
-    /**
42
-     * Handle the Invoice "deleted" event.
43
-     */
44
-    public function deleted(Invoice $invoice): void
45
-    {
46
-        //
47
-    }
48
-
49 49
     /**
50 50
      * Handle the Invoice "restored" event.
51 51
      */

+ 4
- 0
app/Observers/TransactionObserver.php Прегледај датотеку

@@ -78,6 +78,10 @@ class TransactionObserver
78 78
 
79 79
             $invoice = $transaction->transactionable;
80 80
 
81
+            if ($invoice instanceof Invoice && ! $invoice->exists) {
82
+                return;
83
+            }
84
+
81 85
             if ($invoice instanceof Invoice) {
82 86
                 $this->updateInvoiceTotals($invoice, $transaction);
83 87
             }

+ 1
- 0
app/Providers/FilamentCompaniesServiceProvider.php Прегледај датотеку

@@ -260,6 +260,7 @@ class FilamentCompaniesServiceProvider extends PanelProvider
260 260
         Tables\Actions\EditAction::configureUsing(static fn (Tables\Actions\EditAction $action) => FilamentComponentConfigurator::configureActionModals($action));
261 261
         Tables\Actions\CreateAction::configureUsing(static fn (Tables\Actions\CreateAction $action) => FilamentComponentConfigurator::configureActionModals($action));
262 262
         Tables\Actions\DeleteAction::configureUsing(static fn (Tables\Actions\DeleteAction $action) => FilamentComponentConfigurator::configureDeleteAction($action));
263
+        Tables\Actions\DeleteBulkAction::configureUsing(static fn (Tables\Actions\DeleteBulkAction $action) => FilamentComponentConfigurator::configureDeleteAction($action));
263 264
         Forms\Components\DateTimePicker::configureUsing(static function (Forms\Components\DateTimePicker $component) {
264 265
             $component->native(false);
265 266
         });

+ 7
- 0
app/Utilities/Currency/CurrencyConverter.php Прегледај датотеку

@@ -35,6 +35,13 @@ class CurrencyConverter
35 35
         return money($amount, $currency, true)->getAmount();
36 36
     }
37 37
 
38
+    public static function convertCentsToFormatSimple(int $amount, ?string $currency = null): string
39
+    {
40
+        $currency ??= CurrencyAccessor::getDefaultCurrency();
41
+
42
+        return money($amount, $currency)->formatSimple();
43
+    }
44
+
38 45
     public static function convertToCents(string | float $amount, ?string $currency = null): int
39 46
     {
40 47
         $currency ??= CurrencyAccessor::getDefaultCurrency();

+ 25
- 25
composer.lock Прегледај датотеку

@@ -6480,7 +6480,7 @@
6480 6480
         },
6481 6481
         {
6482 6482
             "name": "squirephp/model",
6483
-            "version": "v3.6.0",
6483
+            "version": "v3.7.0",
6484 6484
             "source": {
6485 6485
                 "type": "git",
6486 6486
                 "url": "https://github.com/squirephp/model.git",
@@ -6534,7 +6534,7 @@
6534 6534
         },
6535 6535
         {
6536 6536
             "name": "squirephp/repository",
6537
-            "version": "v3.6.0",
6537
+            "version": "v3.7.0",
6538 6538
             "source": {
6539 6539
                 "type": "git",
6540 6540
                 "url": "https://github.com/squirephp/repository.git",
@@ -6553,12 +6553,12 @@
6553 6553
             "type": "library",
6554 6554
             "extra": {
6555 6555
                 "laravel": {
6556
-                    "providers": [
6557
-                        "Squire\\RepositoryServiceProvider"
6558
-                    ],
6559 6556
                     "aliases": {
6560 6557
                         "RepositoryManager": "Squire\\Repository\\Facades\\Repository"
6561
-                    }
6558
+                    },
6559
+                    "providers": [
6560
+                        "Squire\\RepositoryServiceProvider"
6561
+                    ]
6562 6562
                 }
6563 6563
             },
6564 6564
             "autoload": {
@@ -10007,38 +10007,38 @@
10007 10007
         },
10008 10008
         {
10009 10009
             "name": "pestphp/pest",
10010
-            "version": "v3.5.1",
10010
+            "version": "v3.5.2",
10011 10011
             "source": {
10012 10012
                 "type": "git",
10013 10013
                 "url": "https://github.com/pestphp/pest.git",
10014
-                "reference": "179d46ce97d52bcb3f791449ae94025c3f32e3e3"
10014
+                "reference": "982353fb38703518c1370d420733a7b61ebc997f"
10015 10015
             },
10016 10016
             "dist": {
10017 10017
                 "type": "zip",
10018
-                "url": "https://api.github.com/repos/pestphp/pest/zipball/179d46ce97d52bcb3f791449ae94025c3f32e3e3",
10019
-                "reference": "179d46ce97d52bcb3f791449ae94025c3f32e3e3",
10018
+                "url": "https://api.github.com/repos/pestphp/pest/zipball/982353fb38703518c1370d420733a7b61ebc997f",
10019
+                "reference": "982353fb38703518c1370d420733a7b61ebc997f",
10020 10020
                 "shasum": ""
10021 10021
             },
10022 10022
             "require": {
10023 10023
                 "brianium/paratest": "^7.6.0",
10024 10024
                 "nunomaduro/collision": "^8.5.0",
10025
-                "nunomaduro/termwind": "^2.2.0",
10025
+                "nunomaduro/termwind": "^2.3.0",
10026 10026
                 "pestphp/pest-plugin": "^3.0.0",
10027 10027
                 "pestphp/pest-plugin-arch": "^3.0.0",
10028 10028
                 "pestphp/pest-plugin-mutate": "^3.0.5",
10029 10029
                 "php": "^8.2.0",
10030
-                "phpunit/phpunit": "^11.4.3"
10030
+                "phpunit/phpunit": "^11.4.4"
10031 10031
             },
10032 10032
             "conflict": {
10033 10033
                 "filp/whoops": "<2.16.0",
10034
-                "phpunit/phpunit": ">11.4.3",
10034
+                "phpunit/phpunit": ">11.4.4",
10035 10035
                 "sebastian/exporter": "<6.0.0",
10036 10036
                 "webmozart/assert": "<1.11.0"
10037 10037
             },
10038 10038
             "require-dev": {
10039 10039
                 "pestphp/pest-dev-tools": "^3.3.0",
10040
-                "pestphp/pest-plugin-type-coverage": "^3.1.0",
10041
-                "symfony/process": "^7.1.6"
10040
+                "pestphp/pest-plugin-type-coverage": "^3.2.0",
10041
+                "symfony/process": "^7.1.8"
10042 10042
             },
10043 10043
             "bin": [
10044 10044
                 "bin/pest"
@@ -10103,7 +10103,7 @@
10103 10103
             ],
10104 10104
             "support": {
10105 10105
                 "issues": "https://github.com/pestphp/pest/issues",
10106
-                "source": "https://github.com/pestphp/pest/tree/v3.5.1"
10106
+                "source": "https://github.com/pestphp/pest/tree/v3.5.2"
10107 10107
             },
10108 10108
             "funding": [
10109 10109
                 {
@@ -10115,7 +10115,7 @@
10115 10115
                     "type": "github"
10116 10116
                 }
10117 10117
             ],
10118
-            "time": "2024-10-31T16:12:45+00:00"
10118
+            "time": "2024-12-01T21:28:14+00:00"
10119 10119
         },
10120 10120
         {
10121 10121
             "name": "pestphp/pest-plugin",
@@ -11118,16 +11118,16 @@
11118 11118
         },
11119 11119
         {
11120 11120
             "name": "phpunit/phpunit",
11121
-            "version": "11.4.3",
11121
+            "version": "11.4.4",
11122 11122
             "source": {
11123 11123
                 "type": "git",
11124 11124
                 "url": "https://github.com/sebastianbergmann/phpunit.git",
11125
-                "reference": "e8e8ed1854de5d36c088ec1833beae40d2dedd76"
11125
+                "reference": "f9ba7bd3c9f3ff54ec379d7a1c2e3f13fe0bbde4"
11126 11126
             },
11127 11127
             "dist": {
11128 11128
                 "type": "zip",
11129
-                "url": "https://api.github.com/repos/sebastianbergmann/phpunit/zipball/e8e8ed1854de5d36c088ec1833beae40d2dedd76",
11130
-                "reference": "e8e8ed1854de5d36c088ec1833beae40d2dedd76",
11129
+                "url": "https://api.github.com/repos/sebastianbergmann/phpunit/zipball/f9ba7bd3c9f3ff54ec379d7a1c2e3f13fe0bbde4",
11130
+                "reference": "f9ba7bd3c9f3ff54ec379d7a1c2e3f13fe0bbde4",
11131 11131
                 "shasum": ""
11132 11132
             },
11133 11133
             "require": {
@@ -11137,7 +11137,7 @@
11137 11137
                 "ext-mbstring": "*",
11138 11138
                 "ext-xml": "*",
11139 11139
                 "ext-xmlwriter": "*",
11140
-                "myclabs/deep-copy": "^1.12.0",
11140
+                "myclabs/deep-copy": "^1.12.1",
11141 11141
                 "phar-io/manifest": "^2.0.4",
11142 11142
                 "phar-io/version": "^3.2.1",
11143 11143
                 "php": ">=8.2",
@@ -11148,7 +11148,7 @@
11148 11148
                 "phpunit/php-timer": "^7.0.1",
11149 11149
                 "sebastian/cli-parser": "^3.0.2",
11150 11150
                 "sebastian/code-unit": "^3.0.1",
11151
-                "sebastian/comparator": "^6.1.1",
11151
+                "sebastian/comparator": "^6.2.1",
11152 11152
                 "sebastian/diff": "^6.0.2",
11153 11153
                 "sebastian/environment": "^7.2.0",
11154 11154
                 "sebastian/exporter": "^6.1.3",
@@ -11198,7 +11198,7 @@
11198 11198
             "support": {
11199 11199
                 "issues": "https://github.com/sebastianbergmann/phpunit/issues",
11200 11200
                 "security": "https://github.com/sebastianbergmann/phpunit/security/policy",
11201
-                "source": "https://github.com/sebastianbergmann/phpunit/tree/11.4.3"
11201
+                "source": "https://github.com/sebastianbergmann/phpunit/tree/11.4.4"
11202 11202
             },
11203 11203
             "funding": [
11204 11204
                 {
@@ -11214,7 +11214,7 @@
11214 11214
                     "type": "tidelift"
11215 11215
                 }
11216 11216
             ],
11217
-            "time": "2024-10-28T13:07:50+00:00"
11217
+            "time": "2024-11-27T10:44:52+00:00"
11218 11218
         },
11219 11219
         {
11220 11220
             "name": "pimple/pimple",

+ 40
- 2
database/factories/Accounting/DocumentLineItemFactory.php Прегледај датотеку

@@ -2,13 +2,20 @@
2 2
 
3 3
 namespace Database\Factories\Accounting;
4 4
 
5
+use App\Models\Accounting\DocumentLineItem;
6
+use App\Models\Common\Offering;
5 7
 use Illuminate\Database\Eloquent\Factories\Factory;
6 8
 
7 9
 /**
8
- * @extends \Illuminate\Database\Eloquent\Factories\Factory<\App\Models\Accounting\DocumentLineItem>
10
+ * @extends Factory<DocumentLineItem>
9 11
  */
10 12
 class DocumentLineItemFactory extends Factory
11 13
 {
14
+    /**
15
+     * The name of the factory's corresponding model.
16
+     */
17
+    protected $model = DocumentLineItem::class;
18
+
12 19
     /**
13 20
      * Define the model's default state.
14 21
      *
@@ -16,8 +23,39 @@ class DocumentLineItemFactory extends Factory
16 23
      */
17 24
     public function definition(): array
18 25
     {
26
+        $offering = Offering::with(['salesTaxes', 'salesDiscounts'])->inRandomOrder()->first();
27
+
28
+        $quantity = $this->faker->numberBetween(1, 10);
29
+        $unitPrice = $offering->price;
30
+
19 31
         return [
20
-            //
32
+            'company_id' => 1,
33
+            'offering_id' => 1,
34
+            'description' => $this->faker->sentence,
35
+            'quantity' => $quantity,
36
+            'unit_price' => $unitPrice,
37
+            'created_by' => 1,
38
+            'updated_by' => 1,
21 39
         ];
22 40
     }
41
+
42
+    public function configure(): static
43
+    {
44
+        return $this->afterCreating(function (DocumentLineItem $lineItem) {
45
+            $offering = $lineItem->offering;
46
+
47
+            if ($offering) {
48
+                $lineItem->salesTaxes()->sync($offering->salesTaxes->pluck('id')->toArray());
49
+                $lineItem->salesDiscounts()->sync($offering->salesDiscounts->pluck('id')->toArray());
50
+            }
51
+
52
+            $taxTotal = $lineItem->calculateTaxTotal()->getAmount();
53
+            $discountTotal = $lineItem->calculateDiscountTotal()->getAmount();
54
+
55
+            $lineItem->updateQuietly([
56
+                'tax_total' => $taxTotal,
57
+                'discount_total' => $discountTotal,
58
+            ]);
59
+        });
60
+    }
23 61
 }

+ 131
- 2
database/factories/Accounting/InvoiceFactory.php Прегледај датотеку

@@ -2,13 +2,25 @@
2 2
 
3 3
 namespace Database\Factories\Accounting;
4 4
 
5
+use App\Enums\Accounting\InvoiceStatus;
6
+use App\Enums\Accounting\PaymentMethod;
7
+use App\Models\Accounting\DocumentLineItem;
8
+use App\Models\Accounting\Invoice;
9
+use App\Models\Banking\BankAccount;
10
+use App\Models\Common\Client;
11
+use App\Utilities\Currency\CurrencyConverter;
5 12
 use Illuminate\Database\Eloquent\Factories\Factory;
6 13
 
7 14
 /**
8
- * @extends \Illuminate\Database\Eloquent\Factories\Factory<\App\Models\Accounting\Invoice>
15
+ * @extends Factory<Invoice>
9 16
  */
10 17
 class InvoiceFactory extends Factory
11 18
 {
19
+    /**
20
+     * The name of the factory's corresponding model.
21
+     */
22
+    protected $model = Invoice::class;
23
+
12 24
     /**
13 25
      * Define the model's default state.
14 26
      *
@@ -17,7 +29,124 @@ class InvoiceFactory extends Factory
17 29
     public function definition(): array
18 30
     {
19 31
         return [
20
-            //
32
+            'company_id' => 1,
33
+            'client_id' => Client::inRandomOrder()->value('id'),
34
+            'header' => 'Invoice',
35
+            'subheader' => 'Invoice',
36
+            'invoice_number' => $this->faker->unique()->numerify('INV-#####'),
37
+            'order_number' => $this->faker->unique()->numerify('ORD-#####'),
38
+            'date' => $this->faker->dateTimeBetween('-1 year'),
39
+            'due_date' => $this->faker->dateTimeBetween('now', '+2 months'),
40
+            'status' => InvoiceStatus::Draft,
41
+            'currency_code' => 'USD',
42
+            'terms' => $this->faker->sentence,
43
+            'footer' => $this->faker->sentence,
44
+            'created_by' => 1,
45
+            'updated_by' => 1,
21 46
         ];
22 47
     }
48
+
49
+    public function withLineItems(int $count = 3): self
50
+    {
51
+        return $this->has(DocumentLineItem::factory()->count($count), 'lineItems');
52
+    }
53
+
54
+    public function approved(): static
55
+    {
56
+        return $this->afterCreating(function (Invoice $invoice) {
57
+            if (! $invoice->isDraft()) {
58
+                return;
59
+            }
60
+
61
+            $invoice->approveDraft();
62
+        });
63
+    }
64
+
65
+    public function withPayments(?int $min = 1, ?int $max = null, InvoiceStatus $invoiceStatus = InvoiceStatus::Paid): static
66
+    {
67
+        return $this->afterCreating(function (Invoice $invoice) use ($invoiceStatus, $max, $min) {
68
+            if ($invoice->isDraft()) {
69
+                $invoice->approveDraft();
70
+            }
71
+
72
+            $this->recalculateTotals($invoice);
73
+
74
+            $invoice->refresh();
75
+
76
+            $totalAmountDue = $invoice->getRawOriginal('amount_due');
77
+
78
+            if ($invoiceStatus === InvoiceStatus::Overpaid) {
79
+                $totalAmountDue += random_int(1000, 10000);
80
+            } elseif ($invoiceStatus === InvoiceStatus::Partial) {
81
+                $totalAmountDue = (int) floor($totalAmountDue * 0.5);
82
+            }
83
+
84
+            if ($totalAmountDue <= 0 || empty($totalAmountDue)) {
85
+                return;
86
+            }
87
+
88
+            $paymentCount = $max && $min ? $this->faker->numberBetween($min, $max) : $min;
89
+
90
+            $paymentAmount = (int) floor($totalAmountDue / $paymentCount);
91
+            $remainingAmount = $totalAmountDue;
92
+
93
+            for ($i = 0; $i < $paymentCount; $i++) {
94
+                $amount = $i === $paymentCount - 1 ? $remainingAmount : $paymentAmount;
95
+
96
+                if ($amount <= 0) {
97
+                    break;
98
+                }
99
+
100
+                $data = [
101
+                    'posted_at' => $invoice->date->addDay(),
102
+                    'amount' => CurrencyConverter::convertCentsToFormatSimple($amount, $invoice->currency_code),
103
+                    'payment_method' => $this->faker->randomElement(PaymentMethod::class),
104
+                    'bank_account_id' => BankAccount::inRandomOrder()->value('id'),
105
+                    'notes' => $this->faker->sentence,
106
+                ];
107
+
108
+                $invoice->recordPayment($data);
109
+
110
+                $remainingAmount -= $amount;
111
+            }
112
+        });
113
+    }
114
+
115
+    public function configure(): static
116
+    {
117
+        return $this->afterCreating(function (Invoice $invoice) {
118
+            // Use the invoice's ID to generate invoice and order numbers
119
+            $paddedId = str_pad((string) $invoice->id, 5, '0', STR_PAD_LEFT);
120
+
121
+            $invoice->updateQuietly([
122
+                'invoice_number' => "INV-{$paddedId}",
123
+                'order_number' => "ORD-{$paddedId}",
124
+            ]);
125
+
126
+            $this->recalculateTotals($invoice);
127
+
128
+            if ($invoice->due_date->isPast() && ! in_array($invoice->status, [InvoiceStatus::Draft, InvoiceStatus::Paid, InvoiceStatus::Void, InvoiceStatus::Overpaid])) {
129
+                $invoice->updateQuietly([
130
+                    'status' => InvoiceStatus::Overdue,
131
+                ]);
132
+            }
133
+        });
134
+    }
135
+
136
+    protected function recalculateTotals(Invoice $invoice): void
137
+    {
138
+        if ($invoice->lineItems()->exists()) {
139
+            $subtotal = $invoice->lineItems()->sum('subtotal') / 100;
140
+            $taxTotal = $invoice->lineItems()->sum('tax_total') / 100;
141
+            $discountTotal = $invoice->lineItems()->sum('discount_total') / 100;
142
+            $grandTotal = $subtotal + $taxTotal - $discountTotal;
143
+
144
+            $invoice->updateQuietly([
145
+                'subtotal' => $subtotal,
146
+                'tax_total' => $taxTotal,
147
+                'discount_total' => $discountTotal,
148
+                'total' => $grandTotal,
149
+            ]);
150
+        }
151
+    }
23 152
 }

+ 62
- 0
database/factories/CompanyFactory.php Прегледај датотеку

@@ -2,6 +2,8 @@
2 2
 
3 3
 namespace Database\Factories;
4 4
 
5
+use App\Enums\Accounting\InvoiceStatus;
6
+use App\Models\Accounting\Invoice;
5 7
 use App\Models\Accounting\Transaction;
6 8
 use App\Models\Common\Client;
7 9
 use App\Models\Common\Offering;
@@ -95,4 +97,64 @@ class CompanyFactory extends Factory
95 97
                 ]);
96 98
         });
97 99
     }
100
+
101
+    public function withInvoices(int $count = 10): self
102
+    {
103
+        return $this->afterCreating(function (Company $company) use ($count) {
104
+            $draftCount = (int) floor($count * 0.2);
105
+            $approvedCount = (int) floor($count * 0.2);
106
+            $paidCount = (int) floor($count * 0.3);
107
+            $partialCount = (int) floor($count * 0.2);
108
+            $overpaidCount = $count - ($draftCount + $approvedCount + $paidCount + $partialCount);
109
+
110
+            Invoice::factory()
111
+                ->count($draftCount)
112
+                ->withLineItems()
113
+                ->create([
114
+                    'company_id' => $company->id,
115
+                    'created_by' => $company->user_id,
116
+                    'updated_by' => $company->user_id,
117
+                ]);
118
+
119
+            Invoice::factory()
120
+                ->count($approvedCount)
121
+                ->withLineItems()
122
+                ->approved()
123
+                ->create([
124
+                    'company_id' => $company->id,
125
+                    'created_by' => $company->user_id,
126
+                    'updated_by' => $company->user_id,
127
+                ]);
128
+
129
+            Invoice::factory()
130
+                ->count($paidCount)
131
+                ->withLineItems()
132
+                ->withPayments(max: 4)
133
+                ->create([
134
+                    'company_id' => $company->id,
135
+                    'created_by' => $company->user_id,
136
+                    'updated_by' => $company->user_id,
137
+                ]);
138
+
139
+            Invoice::factory()
140
+                ->count($partialCount)
141
+                ->withLineItems()
142
+                ->withPayments(max: 4, invoiceStatus: InvoiceStatus::Partial)
143
+                ->create([
144
+                    'company_id' => $company->id,
145
+                    'created_by' => $company->user_id,
146
+                    'updated_by' => $company->user_id,
147
+                ]);
148
+
149
+            Invoice::factory()
150
+                ->count($overpaidCount)
151
+                ->withLineItems()
152
+                ->withPayments(max: 4, invoiceStatus: InvoiceStatus::Overpaid)
153
+                ->create([
154
+                    'company_id' => $company->id,
155
+                    'created_by' => $company->user_id,
156
+                    'updated_by' => $company->user_id,
157
+                ]);
158
+        });
159
+    }
98 160
 }

+ 2
- 1
database/seeders/DatabaseSeeder.php Прегледај датотеку

@@ -23,7 +23,8 @@ class DatabaseSeeder extends Seeder
23 23
                     ->withTransactions()
24 24
                     ->withOfferings()
25 25
                     ->withClients()
26
-                    ->withVendors();
26
+                    ->withVendors()
27
+                    ->withInvoices(50);
27 28
             })
28 29
             ->create([
29 30
                 'name' => 'Admin',

Loading…
Откажи
Сачувај