Pārlūkot izejas kodu

wip Recurring Invoices

3.x
Andrew Wallo 9 mēnešus atpakaļ
vecāks
revīzija
679d7100dc
28 mainītis faili ar 1418 papildinājumiem un 14 dzēšanām
  1. 82
    0
      app/Enums/Accounting/DayOfMonth.php
  2. 24
    0
      app/Enums/Accounting/DayOfWeek.php
  3. 35
    0
      app/Enums/Accounting/EndType.php
  4. 97
    0
      app/Enums/Accounting/Frequency.php
  5. 19
    0
      app/Enums/Accounting/IntervalModifier.php
  6. 46
    0
      app/Enums/Accounting/IntervalType.php
  7. 29
    0
      app/Enums/Accounting/Month.php
  8. 29
    0
      app/Enums/Accounting/RecurringInvoiceStatus.php
  9. 0
    2
      app/Filament/Company/Resources/Purchases/BillResource.php
  10. 0
    2
      app/Filament/Company/Resources/Purchases/VendorResource.php
  11. 0
    2
      app/Filament/Company/Resources/Sales/EstimateResource.php
  12. 0
    2
      app/Filament/Company/Resources/Sales/InvoiceResource.php
  13. 304
    0
      app/Filament/Company/Resources/Sales/RecurringInvoiceResource.php
  14. 36
    0
      app/Filament/Company/Resources/Sales/RecurringInvoiceResource/Pages/CreateRecurringInvoice.php
  15. 46
    0
      app/Filament/Company/Resources/Sales/RecurringInvoiceResource/Pages/EditRecurringInvoice.php
  16. 19
    0
      app/Filament/Company/Resources/Sales/RecurringInvoiceResource/Pages/ListRecurringInvoices.php
  17. 299
    0
      app/Filament/Company/Resources/Sales/RecurringInvoiceResource/Pages/ViewRecurringInvoice.php
  18. 45
    0
      app/Filament/Forms/Components/LabeledField.php
  19. 6
    0
      app/Models/Accounting/Invoice.php
  20. 164
    0
      app/Models/Accounting/RecurringInvoice.php
  21. 5
    0
      app/Models/Company.php
  22. 2
    0
      app/Providers/FilamentCompaniesServiceProvider.php
  23. 5
    5
      composer.lock
  24. 23
    0
      database/factories/Accounting/RecurringInvoiceFactory.php
  25. 65
    0
      database/migrations/2024_11_27_223001_create_recurring_invoices_table.php
  26. 1
    0
      database/migrations/2024_11_27_223015_create_invoices_table.php
  27. 2
    1
      resources/data/lang/en.json
  28. 35
    0
      resources/views/filament/forms/components/labeled-field.blade.php

+ 82
- 0
app/Enums/Accounting/DayOfMonth.php Parādīt failu

@@ -0,0 +1,82 @@
1
+<?php
2
+
3
+namespace App\Enums\Accounting;
4
+
5
+use App\Enums\Concerns\ParsesEnum;
6
+use Filament\Support\Contracts\HasLabel;
7
+
8
+enum DayOfMonth: int implements HasLabel
9
+{
10
+    use ParsesEnum;
11
+
12
+    case First = 1;
13
+    case Last = -1;
14
+    case Second = 2;
15
+    case Third = 3;
16
+    case Fourth = 4;
17
+    case Fifth = 5;
18
+    case Sixth = 6;
19
+    case Seventh = 7;
20
+    case Eighth = 8;
21
+    case Ninth = 9;
22
+    case Tenth = 10;
23
+    case Eleventh = 11;
24
+    case Twelfth = 12;
25
+    case Thirteenth = 13;
26
+    case Fourteenth = 14;
27
+    case Fifteenth = 15;
28
+    case Sixteenth = 16;
29
+    case Seventeenth = 17;
30
+    case Eighteenth = 18;
31
+    case Nineteenth = 19;
32
+    case Twentieth = 20;
33
+    case TwentyFirst = 21;
34
+    case TwentySecond = 22;
35
+    case TwentyThird = 23;
36
+    case TwentyFourth = 24;
37
+    case TwentyFifth = 25;
38
+    case TwentySixth = 26;
39
+    case TwentySeventh = 27;
40
+    case TwentyEighth = 28;
41
+    case TwentyNinth = 29;
42
+    case Thirtieth = 30;
43
+    case ThirtyFirst = 31;
44
+
45
+    public function getLabel(): ?string
46
+    {
47
+        return match ($this) {
48
+            self::First => 'First',
49
+            self::Last => 'Last',
50
+            self::Second => '2nd',
51
+            self::Third => '3rd',
52
+            self::Fourth => '4th',
53
+            self::Fifth => '5th',
54
+            self::Sixth => '6th',
55
+            self::Seventh => '7th',
56
+            self::Eighth => '8th',
57
+            self::Ninth => '9th',
58
+            self::Tenth => '10th',
59
+            self::Eleventh => '11th',
60
+            self::Twelfth => '12th',
61
+            self::Thirteenth => '13th',
62
+            self::Fourteenth => '14th',
63
+            self::Fifteenth => '15th',
64
+            self::Sixteenth => '16th',
65
+            self::Seventeenth => '17th',
66
+            self::Eighteenth => '18th',
67
+            self::Nineteenth => '19th',
68
+            self::Twentieth => '20th',
69
+            self::TwentyFirst => '21st',
70
+            self::TwentySecond => '22nd',
71
+            self::TwentyThird => '23rd',
72
+            self::TwentyFourth => '24th',
73
+            self::TwentyFifth => '25th',
74
+            self::TwentySixth => '26th',
75
+            self::TwentySeventh => '27th',
76
+            self::TwentyEighth => '28th',
77
+            self::TwentyNinth => '29th',
78
+            self::Thirtieth => '30th',
79
+            self::ThirtyFirst => '31st',
80
+        };
81
+    }
82
+}

+ 24
- 0
app/Enums/Accounting/DayOfWeek.php Parādīt failu

@@ -0,0 +1,24 @@
1
+<?php
2
+
3
+namespace App\Enums\Accounting;
4
+
5
+use App\Enums\Concerns\ParsesEnum;
6
+use Filament\Support\Contracts\HasLabel;
7
+
8
+enum DayOfWeek: int implements HasLabel
9
+{
10
+    use ParsesEnum;
11
+
12
+    case Sunday = 0;
13
+    case Monday = 1;
14
+    case Tuesday = 2;
15
+    case Wednesday = 3;
16
+    case Thursday = 4;
17
+    case Friday = 5;
18
+    case Saturday = 6;
19
+
20
+    public function getLabel(): ?string
21
+    {
22
+        return $this->name;
23
+    }
24
+}

+ 35
- 0
app/Enums/Accounting/EndType.php Parādīt failu

@@ -0,0 +1,35 @@
1
+<?php
2
+
3
+namespace App\Enums\Accounting;
4
+
5
+use App\Enums\Concerns\ParsesEnum;
6
+use Filament\Support\Contracts\HasLabel;
7
+
8
+enum EndType: string implements HasLabel
9
+{
10
+    use ParsesEnum;
11
+
12
+    case Never = 'never';
13
+    case After = 'after';
14
+    case On = 'on';
15
+
16
+    public function getLabel(): ?string
17
+    {
18
+        return $this->name;
19
+    }
20
+
21
+    public function isNever(): bool
22
+    {
23
+        return $this === self::Never;
24
+    }
25
+
26
+    public function isAfter(): bool
27
+    {
28
+        return $this === self::After;
29
+    }
30
+
31
+    public function isOn(): bool
32
+    {
33
+        return $this === self::On;
34
+    }
35
+}

+ 97
- 0
app/Enums/Accounting/Frequency.php Parādīt failu

@@ -0,0 +1,97 @@
1
+<?php
2
+
3
+namespace App\Enums\Accounting;
4
+
5
+use App\Enums\Concerns\ParsesEnum;
6
+use Filament\Support\Contracts\HasLabel;
7
+
8
+enum Frequency: string implements HasLabel
9
+{
10
+    use ParsesEnum;
11
+
12
+    case Daily = 'daily';
13
+    case Weekly = 'weekly';
14
+    case Monthly = 'monthly';
15
+    case Yearly = 'yearly';
16
+    case Custom = 'custom';
17
+
18
+    public function getLabel(): ?string
19
+    {
20
+        return $this->name;
21
+    }
22
+
23
+    public function getOptions(): array
24
+    {
25
+        return match ($this) {
26
+            self::Weekly => [
27
+                1 => 'Monday',
28
+                2 => 'Tuesday',
29
+                3 => 'Wednesday',
30
+                4 => 'Thursday',
31
+                5 => 'Friday',
32
+                6 => 'Saturday',
33
+                7 => 'Sunday',
34
+            ],
35
+            self::Monthly, self::Yearly => [
36
+                1 => 'First',
37
+                -1 => 'Last',
38
+                2 => '2nd',
39
+                3 => '3rd',
40
+                4 => '4th',
41
+                5 => '5th',
42
+                6 => '6th',
43
+                7 => '7th',
44
+                8 => '8th',
45
+                9 => '9th',
46
+                10 => '10th',
47
+                11 => '11th',
48
+                12 => '12th',
49
+                13 => '13th',
50
+                14 => '14th',
51
+                15 => '15th',
52
+                16 => '16th',
53
+                17 => '17th',
54
+                18 => '18th',
55
+                19 => '19th',
56
+                20 => '20th',
57
+                21 => '21st',
58
+                22 => '22nd',
59
+                23 => '23rd',
60
+                24 => '24th',
61
+                25 => '25th',
62
+                26 => '26th',
63
+                27 => '27th',
64
+                28 => '28th',
65
+                29 => '29th',
66
+                30 => '30th',
67
+                31 => '31st',
68
+            ],
69
+            default => [],
70
+        };
71
+    }
72
+
73
+    public function isDaily(): bool
74
+    {
75
+        return $this === self::Daily;
76
+    }
77
+
78
+    public function isWeekly(): bool
79
+    {
80
+        return $this === self::Weekly;
81
+    }
82
+
83
+    public function isMonthly(): bool
84
+    {
85
+        return $this === self::Monthly;
86
+    }
87
+
88
+    public function isYearly(): bool
89
+    {
90
+        return $this === self::Yearly;
91
+    }
92
+
93
+    public function isCustom(): bool
94
+    {
95
+        return $this === self::Custom;
96
+    }
97
+}

+ 19
- 0
app/Enums/Accounting/IntervalModifier.php Parādīt failu

@@ -0,0 +1,19 @@
1
+<?php
2
+
3
+namespace App\Enums\Accounting;
4
+
5
+use Filament\Support\Contracts\HasLabel;
6
+
7
+enum IntervalModifier: string implements HasLabel
8
+{
9
+    case First = 'first';
10
+    case Second = 'second';
11
+    case Third = 'third';
12
+    case Fourth = 'fourth';
13
+    case Last = 'last';
14
+
15
+    public function getLabel(): ?string
16
+    {
17
+        return $this->name;
18
+    }
19
+}

+ 46
- 0
app/Enums/Accounting/IntervalType.php Parādīt failu

@@ -0,0 +1,46 @@
1
+<?php
2
+
3
+namespace App\Enums\Accounting;
4
+
5
+use App\Enums\Concerns\ParsesEnum;
6
+use Filament\Support\Contracts\HasLabel;
7
+
8
+enum IntervalType: string implements HasLabel
9
+{
10
+    use ParsesEnum;
11
+
12
+    case Day = 'day';
13
+    case Week = 'week';
14
+    case Month = 'month';
15
+    case Year = 'year';
16
+
17
+    public function getLabel(): ?string
18
+    {
19
+        return match ($this) {
20
+            self::Day => 'Day(s)',
21
+            self::Week => 'Week(s)',
22
+            self::Month => 'Month(s)',
23
+            self::Year => 'Year(s)',
24
+        };
25
+    }
26
+
27
+    public function isDay(): bool
28
+    {
29
+        return $this === self::Day;
30
+    }
31
+
32
+    public function isWeek(): bool
33
+    {
34
+        return $this === self::Week;
35
+    }
36
+
37
+    public function isMonth(): bool
38
+    {
39
+        return $this === self::Month;
40
+    }
41
+
42
+    public function isYear(): bool
43
+    {
44
+        return $this === self::Year;
45
+    }
46
+}

+ 29
- 0
app/Enums/Accounting/Month.php Parādīt failu

@@ -0,0 +1,29 @@
1
+<?php
2
+
3
+namespace App\Enums\Accounting;
4
+
5
+use App\Enums\Concerns\ParsesEnum;
6
+use Filament\Support\Contracts\HasLabel;
7
+
8
+enum Month: int implements HasLabel
9
+{
10
+    use ParsesEnum;
11
+
12
+    case January = 1;
13
+    case February = 2;
14
+    case March = 3;
15
+    case April = 4;
16
+    case May = 5;
17
+    case June = 6;
18
+    case July = 7;
19
+    case August = 8;
20
+    case September = 9;
21
+    case October = 10;
22
+    case November = 11;
23
+    case December = 12;
24
+
25
+    public function getLabel(): ?string
26
+    {
27
+        return $this->name;
28
+    }
29
+}

+ 29
- 0
app/Enums/Accounting/RecurringInvoiceStatus.php Parādīt failu

@@ -0,0 +1,29 @@
1
+<?php
2
+
3
+namespace App\Enums\Accounting;
4
+
5
+use Filament\Support\Contracts\HasColor;
6
+use Filament\Support\Contracts\HasLabel;
7
+
8
+enum RecurringInvoiceStatus: string implements HasColor, HasLabel
9
+{
10
+    case Draft = 'draft';
11
+    case Active = 'active';
12
+    case Paused = 'paused';
13
+    case Ended = 'ended';
14
+
15
+    public function getLabel(): ?string
16
+    {
17
+        return $this->name;
18
+    }
19
+
20
+    public function getColor(): string | array | null
21
+    {
22
+        return match ($this) {
23
+            self::Draft => 'gray',
24
+            self::Active => 'primary',
25
+            self::Paused => 'warning',
26
+            self::Ended => 'success',
27
+        };
28
+    }
29
+}

+ 0
- 2
app/Filament/Company/Resources/Purchases/BillResource.php Parādīt failu

@@ -38,8 +38,6 @@ class BillResource extends Resource
38 38
 {
39 39
     protected static ?string $model = Bill::class;
40 40
 
41
-    protected static ?string $navigationIcon = 'heroicon-o-rectangle-stack';
42
-
43 41
     public static function form(Form $form): Form
44 42
     {
45 43
         $company = Auth::user()->currentCompany;

+ 0
- 2
app/Filament/Company/Resources/Purchases/VendorResource.php Parādīt failu

@@ -19,8 +19,6 @@ class VendorResource extends Resource
19 19
 {
20 20
     protected static ?string $model = Vendor::class;
21 21
 
22
-    protected static ?string $navigationIcon = 'heroicon-o-rectangle-stack';
23
-
24 22
     public static function form(Form $form): Form
25 23
     {
26 24
         return $form

+ 0
- 2
app/Filament/Company/Resources/Sales/EstimateResource.php Parādīt failu

@@ -36,8 +36,6 @@ class EstimateResource extends Resource
36 36
 {
37 37
     protected static ?string $model = Estimate::class;
38 38
 
39
-    protected static ?string $navigationIcon = 'heroicon-o-rectangle-stack';
40
-
41 39
     public static function form(Form $form): Form
42 40
     {
43 41
         $company = Auth::user()->currentCompany;

+ 0
- 2
app/Filament/Company/Resources/Sales/InvoiceResource.php Parādīt failu

@@ -43,8 +43,6 @@ class InvoiceResource extends Resource
43 43
 {
44 44
     protected static ?string $model = Invoice::class;
45 45
 
46
-    protected static ?string $navigationIcon = 'heroicon-o-rectangle-stack';
47
-
48 46
     public static function form(Form $form): Form
49 47
     {
50 48
         $company = Auth::user()->currentCompany;

+ 304
- 0
app/Filament/Company/Resources/Sales/RecurringInvoiceResource.php Parādīt failu

@@ -0,0 +1,304 @@
1
+<?php
2
+
3
+namespace App\Filament\Company\Resources\Sales;
4
+
5
+use App\Enums\Accounting\DocumentDiscountMethod;
6
+use App\Enums\Accounting\DocumentType;
7
+use App\Enums\Setting\PaymentTerms;
8
+use App\Filament\Company\Resources\Sales\RecurringInvoiceResource\Pages;
9
+use App\Filament\Forms\Components\CreateCurrencySelect;
10
+use App\Filament\Forms\Components\DocumentTotals;
11
+use App\Models\Accounting\Adjustment;
12
+use App\Models\Accounting\RecurringInvoice;
13
+use App\Models\Common\Client;
14
+use App\Models\Common\Offering;
15
+use App\Utilities\Currency\CurrencyAccessor;
16
+use App\Utilities\Currency\CurrencyConverter;
17
+use App\Utilities\RateCalculator;
18
+use Awcodes\TableRepeater\Components\TableRepeater;
19
+use Awcodes\TableRepeater\Header;
20
+use Filament\Forms;
21
+use Filament\Forms\Components\FileUpload;
22
+use Filament\Forms\Form;
23
+use Filament\Resources\Resource;
24
+use Filament\Support\Enums\MaxWidth;
25
+use Filament\Tables;
26
+use Filament\Tables\Table;
27
+use Illuminate\Support\Facades\Auth;
28
+use Livewire\Features\SupportFileUploads\TemporaryUploadedFile;
29
+
30
+class RecurringInvoiceResource extends Resource
31
+{
32
+    protected static ?string $model = RecurringInvoice::class;
33
+
34
+    public static function form(Form $form): Form
35
+    {
36
+        $company = Auth::user()->currentCompany;
37
+
38
+        return $form
39
+            ->schema([
40
+                Forms\Components\Section::make('Invoice Header')
41
+                    ->collapsible()
42
+                    ->collapsed()
43
+                    ->schema([
44
+                        Forms\Components\Split::make([
45
+                            Forms\Components\Group::make([
46
+                                FileUpload::make('logo')
47
+                                    ->openable()
48
+                                    ->maxSize(1024)
49
+                                    ->localizeLabel()
50
+                                    ->visibility('public')
51
+                                    ->disk('public')
52
+                                    ->directory('logos/document')
53
+                                    ->imageResizeMode('contain')
54
+                                    ->imageCropAspectRatio('3:2')
55
+                                    ->panelAspectRatio('3:2')
56
+                                    ->maxWidth(MaxWidth::ExtraSmall)
57
+                                    ->panelLayout('integrated')
58
+                                    ->removeUploadedFileButtonPosition('center bottom')
59
+                                    ->uploadButtonPosition('center bottom')
60
+                                    ->uploadProgressIndicatorPosition('center bottom')
61
+                                    ->getUploadedFileNameForStorageUsing(
62
+                                        static fn (TemporaryUploadedFile $file): string => (string) str($file->getClientOriginalName())
63
+                                            ->prepend(Auth::user()->currentCompany->id . '_'),
64
+                                    )
65
+                                    ->acceptedFileTypes(['image/png', 'image/jpeg', 'image/gif']),
66
+                            ]),
67
+                            Forms\Components\Group::make([
68
+                                Forms\Components\TextInput::make('header')
69
+                                    ->default(fn () => $company->defaultInvoice->header),
70
+                                Forms\Components\TextInput::make('subheader')
71
+                                    ->default(fn () => $company->defaultInvoice->subheader),
72
+                                Forms\Components\View::make('filament.forms.components.company-info')
73
+                                    ->viewData([
74
+                                        'company_name' => $company->name,
75
+                                        'company_address' => $company->profile->address,
76
+                                        'company_city' => $company->profile->city?->name,
77
+                                        'company_state' => $company->profile->state?->name,
78
+                                        'company_zip' => $company->profile->zip_code,
79
+                                        'company_country' => $company->profile->state?->country->name,
80
+                                    ]),
81
+                            ])->grow(true),
82
+                        ])->from('md'),
83
+                    ]),
84
+                Forms\Components\Section::make('Invoice Details')
85
+                    ->schema([
86
+                        Forms\Components\Split::make([
87
+                            Forms\Components\Group::make([
88
+                                Forms\Components\Select::make('client_id')
89
+                                    ->relationship('client', 'name')
90
+                                    ->preload()
91
+                                    ->searchable()
92
+                                    ->required()
93
+                                    ->live()
94
+                                    ->afterStateUpdated(function (Forms\Set $set, Forms\Get $get, $state) {
95
+                                        if (! $state) {
96
+                                            return;
97
+                                        }
98
+
99
+                                        $currencyCode = Client::find($state)?->currency_code;
100
+
101
+                                        if ($currencyCode) {
102
+                                            $set('currency_code', $currencyCode);
103
+                                        }
104
+                                    }),
105
+                                CreateCurrencySelect::make('currency_code'),
106
+                            ]),
107
+                            Forms\Components\Group::make([
108
+                                Forms\Components\Placeholder::make('invoice_number')
109
+                                    ->label('Invoice Number')
110
+                                    ->content('Auto-generated'),
111
+                                Forms\Components\TextInput::make('order_number')
112
+                                    ->label('P.O/S.O Number'),
113
+                                Forms\Components\Placeholder::make('date')
114
+                                    ->label('Invoice Date')
115
+                                    ->content('Auto-generated'),
116
+                                Forms\Components\Select::make('payment_terms')
117
+                                    ->label('Payment Due')
118
+                                    ->options(PaymentTerms::class)
119
+                                    ->softRequired()
120
+                                    ->default($company->defaultInvoice->payment_terms)
121
+                                    ->live(),
122
+                                Forms\Components\Select::make('discount_method')
123
+                                    ->label('Discount Method')
124
+                                    ->options(DocumentDiscountMethod::class)
125
+                                    ->selectablePlaceholder(false)
126
+                                    ->default(DocumentDiscountMethod::PerLineItem)
127
+                                    ->afterStateUpdated(function ($state, Forms\Set $set) {
128
+                                        $discountMethod = DocumentDiscountMethod::parse($state);
129
+
130
+                                        if ($discountMethod->isPerDocument()) {
131
+                                            $set('lineItems.*.salesDiscounts', []);
132
+                                        }
133
+                                    })
134
+                                    ->live(),
135
+                            ])->grow(true),
136
+                        ])->from('md'),
137
+                        TableRepeater::make('lineItems')
138
+                            ->relationship()
139
+                            ->saveRelationshipsUsing(null)
140
+                            ->dehydrated(true)
141
+                            ->headers(function (Forms\Get $get) {
142
+                                $hasDiscounts = DocumentDiscountMethod::parse($get('discount_method'))->isPerLineItem();
143
+
144
+                                $headers = [
145
+                                    Header::make('Items')->width($hasDiscounts ? '15%' : '20%'),
146
+                                    Header::make('Description')->width($hasDiscounts ? '25%' : '30%'),  // Increase when no discounts
147
+                                    Header::make('Quantity')->width('10%'),
148
+                                    Header::make('Price')->width('10%'),
149
+                                    Header::make('Taxes')->width($hasDiscounts ? '15%' : '20%'),       // Increase when no discounts
150
+                                ];
151
+
152
+                                if ($hasDiscounts) {
153
+                                    $headers[] = Header::make('Discounts')->width('15%');
154
+                                }
155
+
156
+                                $headers[] = Header::make('Amount')->width('10%')->align('right');
157
+
158
+                                return $headers;
159
+                            })
160
+                            ->schema([
161
+                                Forms\Components\Select::make('offering_id')
162
+                                    ->relationship('sellableOffering', 'name')
163
+                                    ->preload()
164
+                                    ->searchable()
165
+                                    ->required()
166
+                                    ->live()
167
+                                    ->afterStateUpdated(function (Forms\Set $set, Forms\Get $get, $state) {
168
+                                        $offeringId = $state;
169
+                                        $offeringRecord = Offering::with(['salesTaxes', 'salesDiscounts'])->find($offeringId);
170
+
171
+                                        if ($offeringRecord) {
172
+                                            $set('description', $offeringRecord->description);
173
+                                            $set('unit_price', $offeringRecord->price);
174
+                                            $set('salesTaxes', $offeringRecord->salesTaxes->pluck('id')->toArray());
175
+
176
+                                            $discountMethod = DocumentDiscountMethod::parse($get('../../discount_method'));
177
+                                            if ($discountMethod->isPerLineItem()) {
178
+                                                $set('salesDiscounts', $offeringRecord->salesDiscounts->pluck('id')->toArray());
179
+                                            }
180
+                                        }
181
+                                    }),
182
+                                Forms\Components\TextInput::make('description'),
183
+                                Forms\Components\TextInput::make('quantity')
184
+                                    ->required()
185
+                                    ->numeric()
186
+                                    ->live()
187
+                                    ->default(1),
188
+                                Forms\Components\TextInput::make('unit_price')
189
+                                    ->hiddenLabel()
190
+                                    ->numeric()
191
+                                    ->live()
192
+                                    ->default(0),
193
+                                Forms\Components\Select::make('salesTaxes')
194
+                                    ->relationship('salesTaxes', 'name')
195
+                                    ->saveRelationshipsUsing(null)
196
+                                    ->dehydrated(true)
197
+                                    ->preload()
198
+                                    ->multiple()
199
+                                    ->live()
200
+                                    ->searchable(),
201
+                                Forms\Components\Select::make('salesDiscounts')
202
+                                    ->relationship('salesDiscounts', 'name')
203
+                                    ->saveRelationshipsUsing(null)
204
+                                    ->dehydrated(true)
205
+                                    ->preload()
206
+                                    ->multiple()
207
+                                    ->live()
208
+                                    ->hidden(function (Forms\Get $get) {
209
+                                        $discountMethod = DocumentDiscountMethod::parse($get('../../discount_method'));
210
+
211
+                                        return $discountMethod->isPerDocument();
212
+                                    })
213
+                                    ->searchable(),
214
+                                Forms\Components\Placeholder::make('total')
215
+                                    ->hiddenLabel()
216
+                                    ->extraAttributes(['class' => 'text-left sm:text-right'])
217
+                                    ->content(function (Forms\Get $get) {
218
+                                        $quantity = max((float) ($get('quantity') ?? 0), 0);
219
+                                        $unitPrice = max((float) ($get('unit_price') ?? 0), 0);
220
+                                        $salesTaxes = $get('salesTaxes') ?? [];
221
+                                        $salesDiscounts = $get('salesDiscounts') ?? [];
222
+                                        $currencyCode = $get('../../currency_code') ?? CurrencyAccessor::getDefaultCurrency();
223
+
224
+                                        $subtotal = $quantity * $unitPrice;
225
+
226
+                                        $subtotalInCents = CurrencyConverter::convertToCents($subtotal, $currencyCode);
227
+
228
+                                        $taxAmountInCents = Adjustment::whereIn('id', $salesTaxes)
229
+                                            ->get()
230
+                                            ->sum(function (Adjustment $adjustment) use ($subtotalInCents) {
231
+                                                if ($adjustment->computation->isPercentage()) {
232
+                                                    return RateCalculator::calculatePercentage($subtotalInCents, $adjustment->getRawOriginal('rate'));
233
+                                                } else {
234
+                                                    return $adjustment->getRawOriginal('rate');
235
+                                                }
236
+                                            });
237
+
238
+                                        $discountAmountInCents = Adjustment::whereIn('id', $salesDiscounts)
239
+                                            ->get()
240
+                                            ->sum(function (Adjustment $adjustment) use ($subtotalInCents) {
241
+                                                if ($adjustment->computation->isPercentage()) {
242
+                                                    return RateCalculator::calculatePercentage($subtotalInCents, $adjustment->getRawOriginal('rate'));
243
+                                                } else {
244
+                                                    return $adjustment->getRawOriginal('rate');
245
+                                                }
246
+                                            });
247
+
248
+                                        // Final total
249
+                                        $totalInCents = $subtotalInCents + ($taxAmountInCents - $discountAmountInCents);
250
+
251
+                                        return CurrencyConverter::formatCentsToMoney($totalInCents, $currencyCode);
252
+                                    }),
253
+                            ]),
254
+                        DocumentTotals::make()
255
+                            ->type(DocumentType::Invoice),
256
+                        Forms\Components\Textarea::make('terms')
257
+                            ->columnSpanFull(),
258
+                    ]),
259
+                Forms\Components\Section::make('Invoice Footer')
260
+                    ->collapsible()
261
+                    ->collapsed()
262
+                    ->schema([
263
+                        Forms\Components\Textarea::make('footer')
264
+                            ->columnSpanFull(),
265
+                    ]),
266
+            ]);
267
+    }
268
+
269
+    public static function table(Table $table): Table
270
+    {
271
+        return $table
272
+            ->columns([
273
+                //
274
+            ])
275
+            ->filters([
276
+                //
277
+            ])
278
+            ->actions([
279
+                Tables\Actions\EditAction::make(),
280
+            ])
281
+            ->bulkActions([
282
+                Tables\Actions\BulkActionGroup::make([
283
+                    Tables\Actions\DeleteBulkAction::make(),
284
+                ]),
285
+            ]);
286
+    }
287
+
288
+    public static function getRelations(): array
289
+    {
290
+        return [
291
+            //
292
+        ];
293
+    }
294
+
295
+    public static function getPages(): array
296
+    {
297
+        return [
298
+            'index' => Pages\ListRecurringInvoices::route('/'),
299
+            'create' => Pages\CreateRecurringInvoice::route('/create'),
300
+            'view' => Pages\ViewRecurringInvoice::route('/{record}'),
301
+            'edit' => Pages\EditRecurringInvoice::route('/{record}/edit'),
302
+        ];
303
+    }
304
+}

+ 36
- 0
app/Filament/Company/Resources/Sales/RecurringInvoiceResource/Pages/CreateRecurringInvoice.php Parādīt failu

@@ -0,0 +1,36 @@
1
+<?php
2
+
3
+namespace App\Filament\Company\Resources\Sales\RecurringInvoiceResource\Pages;
4
+
5
+use App\Concerns\ManagesLineItems;
6
+use App\Filament\Company\Resources\Sales\RecurringInvoiceResource;
7
+use App\Models\Accounting\RecurringInvoice;
8
+use Filament\Resources\Pages\CreateRecord;
9
+use Filament\Support\Enums\MaxWidth;
10
+use Illuminate\Database\Eloquent\Model;
11
+
12
+class CreateRecurringInvoice extends CreateRecord
13
+{
14
+    use ManagesLineItems;
15
+
16
+    protected static string $resource = RecurringInvoiceResource::class;
17
+
18
+    public function getMaxContentWidth(): MaxWidth | string | null
19
+    {
20
+        return MaxWidth::Full;
21
+    }
22
+
23
+    protected function handleRecordCreation(array $data): Model
24
+    {
25
+        /** @var RecurringInvoice $record */
26
+        $record = parent::handleRecordCreation($data);
27
+
28
+        $this->handleLineItems($record, collect($data['lineItems'] ?? []));
29
+
30
+        $totals = $this->updateDocumentTotals($record, $data);
31
+
32
+        $record->updateQuietly($totals);
33
+
34
+        return $record;
35
+    }
36
+}

+ 46
- 0
app/Filament/Company/Resources/Sales/RecurringInvoiceResource/Pages/EditRecurringInvoice.php Parādīt failu

@@ -0,0 +1,46 @@
1
+<?php
2
+
3
+namespace App\Filament\Company\Resources\Sales\RecurringInvoiceResource\Pages;
4
+
5
+use App\Concerns\ManagesLineItems;
6
+use App\Filament\Company\Resources\Sales\RecurringInvoiceResource;
7
+use App\Models\Accounting\Estimate;
8
+use Filament\Actions;
9
+use Filament\Resources\Pages\EditRecord;
10
+use Filament\Support\Enums\MaxWidth;
11
+use Illuminate\Database\Eloquent\Model;
12
+
13
+class EditRecurringInvoice extends EditRecord
14
+{
15
+    use ManagesLineItems;
16
+
17
+    protected static string $resource = RecurringInvoiceResource::class;
18
+
19
+    protected function getHeaderActions(): array
20
+    {
21
+        return [
22
+            Actions\DeleteAction::make(),
23
+        ];
24
+    }
25
+
26
+    public function getMaxContentWidth(): MaxWidth | string | null
27
+    {
28
+        return MaxWidth::Full;
29
+    }
30
+
31
+    protected function handleRecordUpdate(Model $record, array $data): Model
32
+    {
33
+        /** @var Estimate $record */
34
+        $lineItems = collect($data['lineItems'] ?? []);
35
+
36
+        $this->deleteRemovedLineItems($record, $lineItems);
37
+
38
+        $this->handleLineItems($record, $lineItems);
39
+
40
+        $totals = $this->updateDocumentTotals($record, $data);
41
+
42
+        $data = array_merge($data, $totals);
43
+
44
+        return parent::handleRecordUpdate($record, $data);
45
+    }
46
+}

+ 19
- 0
app/Filament/Company/Resources/Sales/RecurringInvoiceResource/Pages/ListRecurringInvoices.php Parādīt failu

@@ -0,0 +1,19 @@
1
+<?php
2
+
3
+namespace App\Filament\Company\Resources\Sales\RecurringInvoiceResource\Pages;
4
+
5
+use App\Filament\Company\Resources\Sales\RecurringInvoiceResource;
6
+use Filament\Actions;
7
+use Filament\Resources\Pages\ListRecords;
8
+
9
+class ListRecurringInvoices extends ListRecords
10
+{
11
+    protected static string $resource = RecurringInvoiceResource::class;
12
+
13
+    protected function getHeaderActions(): array
14
+    {
15
+        return [
16
+            Actions\CreateAction::make(),
17
+        ];
18
+    }
19
+}

+ 299
- 0
app/Filament/Company/Resources/Sales/RecurringInvoiceResource/Pages/ViewRecurringInvoice.php Parādīt failu

@@ -0,0 +1,299 @@
1
+<?php
2
+
3
+namespace App\Filament\Company\Resources\Sales\RecurringInvoiceResource\Pages;
4
+
5
+use App\Enums\Accounting\DayOfMonth;
6
+use App\Enums\Accounting\DayOfWeek;
7
+use App\Enums\Accounting\EndType;
8
+use App\Enums\Accounting\Frequency;
9
+use App\Enums\Accounting\IntervalType;
10
+use App\Enums\Accounting\Month;
11
+use App\Filament\Company\Resources\Sales\RecurringInvoiceResource;
12
+use App\Filament\Forms\Components\LabeledField;
13
+use App\Models\Setting\CompanyProfile;
14
+use App\Utilities\Localization\Timezone;
15
+use Filament\Forms;
16
+use Filament\Forms\Form;
17
+use Filament\Resources\Pages\ViewRecord;
18
+use Filament\Support\Enums\MaxWidth;
19
+use Guava\FilamentClusters\Forms\Cluster;
20
+
21
+class ViewRecurringInvoice extends ViewRecord
22
+{
23
+    protected static string $resource = RecurringInvoiceResource::class;
24
+
25
+    protected function mutateFormDataBeforeFill(array $data): array
26
+    {
27
+        $data['day_of_month'] ??= DayOfMonth::First;
28
+        $data['start_date'] ??= now()->addMonth()->startOfMonth();
29
+
30
+        return $data;
31
+    }
32
+
33
+    public function getMaxContentWidth(): MaxWidth | string | null
34
+    {
35
+        return MaxWidth::SixExtraLarge;
36
+    }
37
+
38
+    public function form(Form $form): Form
39
+    {
40
+        return $form
41
+            ->disabled(false)
42
+            ->schema([
43
+                Forms\Components\Section::make('Scheduling')
44
+                    ->schema([
45
+                        Forms\Components\Group::make([
46
+                            Forms\Components\Select::make('frequency')
47
+                                ->label('Repeat this invoice')
48
+                                ->inlineLabel()
49
+                                ->options(Frequency::class)
50
+                                ->softRequired()
51
+                                ->live()
52
+                                ->afterStateUpdated(function (Forms\Set $set, $state) {
53
+                                    $frequency = Frequency::parse($state);
54
+
55
+                                    if ($frequency->isDaily()) {
56
+                                        $set('interval_value', null);
57
+                                        $set('interval_type', null);
58
+                                    }
59
+
60
+                                    if ($frequency->isWeekly()) {
61
+                                        $currentDayOfWeek = now()->dayOfWeek;
62
+                                        $currentDayOfWeek = DayOfWeek::parse($currentDayOfWeek);
63
+                                        $set('day_of_week', $currentDayOfWeek);
64
+                                        $set('interval_value', null);
65
+                                        $set('interval_type', null);
66
+                                    }
67
+
68
+                                    if ($frequency->isMonthly()) {
69
+                                        $set('day_of_month', DayOfMonth::First);
70
+                                        $set('interval_value', null);
71
+                                        $set('interval_type', null);
72
+                                    }
73
+
74
+                                    if ($frequency->isYearly()) {
75
+                                        $currentMonth = now()->month;
76
+                                        $currentMonth = Month::parse($currentMonth);
77
+                                        $set('month', $currentMonth);
78
+
79
+                                        $currentDay = now()->dayOfMonth;
80
+                                        $currentDay = DayOfMonth::parse($currentDay);
81
+                                        $set('day_of_month', $currentDay);
82
+
83
+                                        $set('interval_value', null);
84
+                                        $set('interval_type', null);
85
+                                    }
86
+
87
+                                    if ($frequency->isCustom()) {
88
+                                        $set('interval_value', 1);
89
+                                        $set('interval_type', IntervalType::Month);
90
+
91
+                                        $currentDay = now()->dayOfMonth;
92
+                                        $currentDay = DayOfMonth::parse($currentDay);
93
+                                        $set('day_of_month', $currentDay);
94
+                                    }
95
+                                }),
96
+
97
+                            // Custom frequency fields
98
+
99
+                            LabeledField::make()
100
+                                ->prefix('every')
101
+                                ->schema([
102
+                                    Cluster::make([
103
+                                        Forms\Components\TextInput::make('interval_value')
104
+                                            ->label('every')
105
+                                            ->numeric()
106
+                                            ->default(1),
107
+                                        Forms\Components\Select::make('interval_type')
108
+                                            ->label('Interval Type')
109
+                                            ->options(IntervalType::class)
110
+                                            ->softRequired()
111
+                                            ->default(IntervalType::Month)
112
+                                            ->live()
113
+                                            ->afterStateUpdated(function (Forms\Set $set, $state) {
114
+                                                $intervalType = IntervalType::parse($state);
115
+
116
+                                                if ($intervalType->isWeek()) {
117
+                                                    $currentDayOfWeek = now()->dayOfWeek;
118
+                                                    $currentDayOfWeek = DayOfWeek::parse($currentDayOfWeek);
119
+                                                    $set('day_of_week', $currentDayOfWeek);
120
+                                                }
121
+
122
+                                                if ($intervalType->isMonth()) {
123
+                                                    $currentDay = now()->dayOfMonth;
124
+                                                    $currentDay = DayOfMonth::parse($currentDay);
125
+                                                    $set('day_of_month', $currentDay);
126
+                                                }
127
+
128
+                                                if ($intervalType->isYear()) {
129
+                                                    $currentMonth = now()->month;
130
+                                                    $currentMonth = Month::parse($currentMonth);
131
+                                                    $set('month', $currentMonth);
132
+
133
+                                                    $currentDay = now()->dayOfMonth;
134
+                                                    $currentDay = DayOfMonth::parse($currentDay);
135
+                                                    $set('day_of_month', $currentDay);
136
+                                                }
137
+                                            }),
138
+                                    ])
139
+                                        ->live()
140
+                                        ->hiddenLabel(),
141
+                                ])
142
+                                ->visible(fn (Forms\Get $get) => Frequency::parse($get('frequency'))->isCustom()),
143
+
144
+                            LabeledField::make()
145
+                                ->prefix(function (Forms\Get $get) {
146
+                                    $frequency = Frequency::parse($get('frequency'));
147
+                                    $intervalType = IntervalType::parse($get('interval_type'));
148
+
149
+                                    if ($frequency->isYearly()) {
150
+                                        return 'every';
151
+                                    }
152
+
153
+                                    if ($frequency->isCustom() && $intervalType?->isYear()) {
154
+                                        return 'in';
155
+                                    }
156
+
157
+                                    return null;
158
+                                })
159
+                                ->schema([
160
+                                    Forms\Components\Select::make('month')
161
+                                        ->hiddenLabel()
162
+                                        ->options(Month::class)
163
+                                        ->live()
164
+                                        ->softRequired(),
165
+                                ])
166
+                                ->visible(fn (Forms\Get $get) => Frequency::parse($get('frequency'))->isYearly() || IntervalType::parse($get('interval_type'))?->isYear()),
167
+
168
+                            LabeledField::make()
169
+                                ->prefix('on the')
170
+                                ->suffix(function (Forms\Get $get) {
171
+                                    $frequency = Frequency::parse($get('frequency'));
172
+                                    $intervalType = IntervalType::parse($get('interval_type'));
173
+
174
+                                    if ($frequency->isMonthly()) {
175
+                                        return 'day of every month';
176
+                                    }
177
+
178
+                                    if ($frequency->isYearly() || ($frequency->isCustom() && $intervalType->isMonth()) || ($frequency->isCustom() && $intervalType->isYear())) {
179
+                                        return 'day of the month';
180
+                                    }
181
+
182
+                                    return null;
183
+                                })
184
+                                ->schema([
185
+                                    Forms\Components\Select::make('day_of_month')
186
+                                        ->hiddenLabel()
187
+                                        ->inlineLabel()
188
+                                        ->options(DayOfMonth::class)
189
+                                        ->live()
190
+                                        ->softRequired(),
191
+                                ])
192
+                                ->visible(fn (Forms\Get $get) => Frequency::parse($get('frequency'))?->isMonthly() || Frequency::parse($get('frequency'))?->isYearly() || IntervalType::parse($get('interval_type'))?->isMonth() || IntervalType::parse($get('interval_type'))?->isYear()),
193
+
194
+                            LabeledField::make()
195
+                                ->prefix(function (Forms\Get $get) {
196
+                                    $frequency = Frequency::parse($get('frequency'));
197
+                                    $intervalType = IntervalType::parse($get('interval_type'));
198
+
199
+                                    if ($frequency->isWeekly()) {
200
+                                        return 'every';
201
+                                    }
202
+
203
+                                    if ($frequency->isCustom() && $intervalType->isWeek()) {
204
+                                        return 'on';
205
+                                    }
206
+
207
+                                    return null;
208
+                                })
209
+                                ->schema([
210
+                                    Forms\Components\Select::make('day_of_week')
211
+                                        ->hiddenLabel()
212
+                                        ->options(DayOfWeek::class)
213
+                                        ->live()
214
+                                        ->softRequired(),
215
+                                ])
216
+                                ->visible(fn (Forms\Get $get) => Frequency::parse($get('frequency'))?->isWeekly() || IntervalType::parse($get('interval_type'))?->isWeek()),
217
+                        ])->columns(2),
218
+
219
+                        Forms\Components\Group::make([
220
+                            Forms\Components\DatePicker::make('start_date')
221
+                                ->label('Create first invoice on')
222
+                                ->inlineLabel()
223
+                                ->softRequired(),
224
+
225
+                            LabeledField::make()
226
+                                ->prefix('and end')
227
+                                ->suffix(function (Forms\Get $get) {
228
+                                    $endType = EndType::parse($get('end_type'));
229
+
230
+                                    if ($endType->isAfter()) {
231
+                                        return 'invoices';
232
+                                    }
233
+
234
+                                    return null;
235
+                                })
236
+                                ->schema(function (Forms\Get $get) {
237
+                                    $components = [];
238
+
239
+                                    $components[] = Forms\Components\Select::make('end_type')
240
+                                        ->hiddenLabel()
241
+                                        ->options(EndType::class)
242
+                                        ->softRequired()
243
+                                        ->live()
244
+                                        ->afterStateUpdated(function (Forms\Set $set, $state) {
245
+                                            $endType = EndType::parse($state);
246
+
247
+                                            if ($endType->isNever()) {
248
+                                                $set('max_occurrences', null);
249
+                                                $set('end_date', null);
250
+                                            }
251
+
252
+                                            if ($endType->isAfter()) {
253
+                                                $set('max_occurrences', 1);
254
+                                                $set('end_date', null);
255
+                                            }
256
+
257
+                                            if ($endType->isOn()) {
258
+                                                $set('max_occurrences', null);
259
+                                                $set('end_date', now()->addMonth()->startOfMonth());
260
+                                            }
261
+                                        });
262
+
263
+                                    $endType = EndType::parse($get('end_type'));
264
+
265
+                                    if ($endType->isAfter()) {
266
+                                        $components[] = Forms\Components\TextInput::make('max_occurrences')
267
+                                            ->numeric()
268
+                                            ->live();
269
+                                    }
270
+
271
+                                    if ($endType->isOn()) {
272
+                                        $components[] = Forms\Components\DatePicker::make('end_date')
273
+                                            ->live();
274
+                                    }
275
+
276
+                                    return [
277
+                                        Cluster::make($components)
278
+                                            ->hiddenLabel(),
279
+                                    ];
280
+                                }),
281
+                        ])->columns(2),
282
+
283
+                        Forms\Components\Group::make([
284
+                            LabeledField::make()
285
+                                ->prefix('Create in')
286
+                                ->suffix('time zone')
287
+                                ->schema([
288
+                                    Forms\Components\Select::make('timezone')
289
+                                        ->softRequired()
290
+                                        ->hiddenLabel()
291
+                                        ->options(Timezone::getTimezoneOptions(CompanyProfile::first()->country))
292
+                                        ->searchable(),
293
+                                ])
294
+                                ->columns(1),
295
+                        ])->columns(2),
296
+                    ]),
297
+            ]);
298
+    }
299
+}

+ 45
- 0
app/Filament/Forms/Components/LabeledField.php Parādīt failu

@@ -0,0 +1,45 @@
1
+<?php
2
+
3
+namespace App\Filament\Forms\Components;
4
+
5
+use Closure;
6
+use Filament\Forms\Components\Component;
7
+use Illuminate\Contracts\Support\Htmlable;
8
+
9
+class LabeledField extends Component
10
+{
11
+    protected string $view = 'filament.forms.components.labeled-field';
12
+
13
+    protected string | Htmlable | Closure | null $prefixLabel = null;
14
+
15
+    protected string | Htmlable | Closure | null $suffixLabel = null;
16
+
17
+    public static function make(): static
18
+    {
19
+        return app(static::class);
20
+    }
21
+
22
+    public function prefix(string | Htmlable | Closure | null $label): static
23
+    {
24
+        $this->prefixLabel = $label;
25
+
26
+        return $this;
27
+    }
28
+
29
+    public function suffix(string | Htmlable | Closure | null $label): static
30
+    {
31
+        $this->suffixLabel = $label;
32
+
33
+        return $this;
34
+    }
35
+
36
+    public function getPrefixLabel(): string | Htmlable | null
37
+    {
38
+        return $this->evaluate($this->prefixLabel);
39
+    }
40
+
41
+    public function getSuffixLabel(): string | Htmlable | null
42
+    {
43
+        return $this->evaluate($this->suffixLabel);
44
+    }
45
+}

+ 6
- 0
app/Models/Accounting/Invoice.php Parādīt failu

@@ -48,6 +48,7 @@ class Invoice extends Model
48 48
         'company_id',
49 49
         'client_id',
50 50
         'estimate_id',
51
+        'recurring_invoice_id',
51 52
         'logo',
52 53
         'header',
53 54
         'subheader',
@@ -109,6 +110,11 @@ class Invoice extends Model
109 110
         return $this->belongsTo(Estimate::class);
110 111
     }
111 112
 
113
+    public function recurringInvoice(): BelongsTo
114
+    {
115
+        return $this->belongsTo(RecurringInvoice::class);
116
+    }
117
+
112 118
     public function lineItems(): MorphMany
113 119
     {
114 120
         return $this->morphMany(DocumentLineItem::class, 'documentable');

+ 164
- 0
app/Models/Accounting/RecurringInvoice.php Parādīt failu

@@ -0,0 +1,164 @@
1
+<?php
2
+
3
+namespace App\Models\Accounting;
4
+
5
+use App\Casts\MoneyCast;
6
+use App\Casts\RateCast;
7
+use App\Collections\Accounting\DocumentCollection;
8
+use App\Concerns\Blamable;
9
+use App\Concerns\CompanyOwned;
10
+use App\Enums\Accounting\AdjustmentComputation;
11
+use App\Enums\Accounting\DayOfMonth;
12
+use App\Enums\Accounting\DayOfWeek;
13
+use App\Enums\Accounting\DocumentDiscountMethod;
14
+use App\Enums\Accounting\EndType;
15
+use App\Enums\Accounting\Frequency;
16
+use App\Enums\Accounting\IntervalType;
17
+use App\Enums\Accounting\Month;
18
+use App\Enums\Accounting\RecurringInvoiceStatus;
19
+use App\Enums\Setting\PaymentTerms;
20
+use App\Models\Common\Client;
21
+use App\Models\Setting\Currency;
22
+use Illuminate\Database\Eloquent\Attributes\CollectedBy;
23
+use Illuminate\Database\Eloquent\Factories\HasFactory;
24
+use Illuminate\Database\Eloquent\Model;
25
+use Illuminate\Database\Eloquent\Relations\BelongsTo;
26
+use Illuminate\Database\Eloquent\Relations\HasMany;
27
+use Illuminate\Database\Eloquent\Relations\MorphMany;
28
+
29
+#[CollectedBy(DocumentCollection::class)]
30
+class RecurringInvoice extends Model
31
+{
32
+    use Blamable;
33
+    use CompanyOwned;
34
+    use HasFactory;
35
+
36
+    protected $table = 'recurring_invoices';
37
+
38
+    protected $fillable = [
39
+        'company_id',
40
+        'client_id',
41
+        'logo',
42
+        'header',
43
+        'subheader',
44
+        'order_number',
45
+        'payment_terms',
46
+        'approved_at',
47
+        'ended_at',
48
+        'frequency',
49
+        'interval_type',
50
+        'interval_value',
51
+        'month',
52
+        'day_of_month',
53
+        'day_of_week',
54
+        'start_date',
55
+        'end_type',
56
+        'max_occurrences',
57
+        'end_date',
58
+        'occurrences_count',
59
+        'timezone',
60
+        'next_date',
61
+        'last_date',
62
+        'auto_send',
63
+        'send_time',
64
+        'status',
65
+        'currency_code',
66
+        'discount_method',
67
+        'discount_computation',
68
+        'discount_rate',
69
+        'subtotal',
70
+        'tax_total',
71
+        'discount_total',
72
+        'total',
73
+        'terms',
74
+        'footer',
75
+        'created_by',
76
+        'updated_by',
77
+    ];
78
+
79
+    protected $casts = [
80
+        'approved_at' => 'datetime',
81
+        'ended_at' => 'datetime',
82
+        'start_date' => 'date',
83
+        'end_date' => 'date',
84
+        'next_date' => 'date',
85
+        'last_date' => 'date',
86
+        'auto_send' => 'boolean',
87
+        'send_time' => 'datetime:H:i',
88
+        'payment_terms' => PaymentTerms::class,
89
+        'frequency' => Frequency::class,
90
+        'interval_type' => IntervalType::class,
91
+        'month' => Month::class,
92
+        'day_of_month' => DayOfMonth::class,
93
+        'day_of_week' => DayOfWeek::class,
94
+        'end_type' => EndType::class,
95
+        'status' => RecurringInvoiceStatus::class,
96
+        'discount_method' => DocumentDiscountMethod::class,
97
+        'discount_computation' => AdjustmentComputation::class,
98
+        'discount_rate' => RateCast::class,
99
+        'subtotal' => MoneyCast::class,
100
+        'tax_total' => MoneyCast::class,
101
+        'discount_total' => MoneyCast::class,
102
+        'total' => MoneyCast::class,
103
+    ];
104
+
105
+    public function client(): BelongsTo
106
+    {
107
+        return $this->belongsTo(Client::class);
108
+    }
109
+
110
+    public function currency(): BelongsTo
111
+    {
112
+        return $this->belongsTo(Currency::class, 'currency_code', 'code');
113
+    }
114
+
115
+    public function invoices(): HasMany
116
+    {
117
+        return $this->hasMany(Invoice::class, 'recurring_invoice_id');
118
+    }
119
+
120
+    public function lineItems(): MorphMany
121
+    {
122
+        return $this->morphMany(DocumentLineItem::class, 'documentable');
123
+    }
124
+
125
+    public function isDraft(): bool
126
+    {
127
+        return $this->status === RecurringInvoiceStatus::Draft;
128
+    }
129
+
130
+    public function isActive(): bool
131
+    {
132
+        return $this->status === RecurringInvoiceStatus::Active;
133
+    }
134
+
135
+    public function wasApproved(): bool
136
+    {
137
+        return $this->approved_at !== null;
138
+    }
139
+
140
+    public function wasEnded(): bool
141
+    {
142
+        return $this->ended_at !== null;
143
+    }
144
+
145
+    public function isNeverEnding(): bool
146
+    {
147
+        return $this->end_type === EndType::Never;
148
+    }
149
+
150
+    public function canBeApproved(): bool
151
+    {
152
+        return $this->isDraft() && ! $this->wasApproved();
153
+    }
154
+
155
+    public function canBeEnded(): bool
156
+    {
157
+        return $this->isActive() && ! $this->wasEnded();
158
+    }
159
+
160
+    public function hasLineItems(): bool
161
+    {
162
+        return $this->lineItems()->exists();
163
+    }
164
+}

+ 5
- 0
app/Models/Company.php Parādīt failu

@@ -152,6 +152,11 @@ class Company extends FilamentCompaniesCompany implements HasAvatar
152 152
         return $this->hasMany(Accounting\Invoice::class, 'company_id');
153 153
     }
154 154
 
155
+    public function recurringInvoices(): HasMany
156
+    {
157
+        return $this->hasMany(Accounting\RecurringInvoice::class, 'company_id');
158
+    }
159
+
155 160
     public function locale(): HasOne
156 161
     {
157 162
         return $this->hasOne(Localization::class, 'company_id');

+ 2
- 0
app/Providers/FilamentCompaniesServiceProvider.php Parādīt failu

@@ -32,6 +32,7 @@ use App\Filament\Company\Resources\Purchases\VendorResource;
32 32
 use App\Filament\Company\Resources\Sales\ClientResource;
33 33
 use App\Filament\Company\Resources\Sales\EstimateResource;
34 34
 use App\Filament\Company\Resources\Sales\InvoiceResource;
35
+use App\Filament\Company\Resources\Sales\RecurringInvoiceResource;
35 36
 use App\Filament\Components\PanelShiftDropdown;
36 37
 use App\Filament\User\Clusters\Account;
37 38
 use App\Http\Middleware\ConfigureCurrentCompany;
@@ -132,6 +133,7 @@ class FilamentCompaniesServiceProvider extends PanelProvider
132 133
                             ->icon('heroicon-o-currency-dollar')
133 134
                             ->items([
134 135
                                 ...InvoiceResource::getNavigationItems(),
136
+                                ...RecurringInvoiceResource::getNavigationItems(),
135 137
                                 ...EstimateResource::getNavigationItems(),
136 138
                                 ...ClientResource::getNavigationItems(),
137 139
                             ]),

+ 5
- 5
composer.lock Parādīt failu

@@ -3517,16 +3517,16 @@
3517 3517
         },
3518 3518
         {
3519 3519
             "name": "league/commonmark",
3520
-            "version": "2.6.0",
3520
+            "version": "2.6.1",
3521 3521
             "source": {
3522 3522
                 "type": "git",
3523 3523
                 "url": "https://github.com/thephpleague/commonmark.git",
3524
-                "reference": "d150f911e0079e90ae3c106734c93137c184f932"
3524
+                "reference": "d990688c91cedfb69753ffc2512727ec646df2ad"
3525 3525
             },
3526 3526
             "dist": {
3527 3527
                 "type": "zip",
3528
-                "url": "https://api.github.com/repos/thephpleague/commonmark/zipball/d150f911e0079e90ae3c106734c93137c184f932",
3529
-                "reference": "d150f911e0079e90ae3c106734c93137c184f932",
3528
+                "url": "https://api.github.com/repos/thephpleague/commonmark/zipball/d990688c91cedfb69753ffc2512727ec646df2ad",
3529
+                "reference": "d990688c91cedfb69753ffc2512727ec646df2ad",
3530 3530
                 "shasum": ""
3531 3531
             },
3532 3532
             "require": {
@@ -3620,7 +3620,7 @@
3620 3620
                     "type": "tidelift"
3621 3621
                 }
3622 3622
             ],
3623
-            "time": "2024-12-07T15:34:16+00:00"
3623
+            "time": "2024-12-29T14:10:59+00:00"
3624 3624
         },
3625 3625
         {
3626 3626
             "name": "league/config",

+ 23
- 0
database/factories/Accounting/RecurringInvoiceFactory.php Parādīt failu

@@ -0,0 +1,23 @@
1
+<?php
2
+
3
+namespace Database\Factories\Accounting;
4
+
5
+use Illuminate\Database\Eloquent\Factories\Factory;
6
+
7
+/**
8
+ * @extends \Illuminate\Database\Eloquent\Factories\Factory<\App\Models\Accounting\RecurringInvoice>
9
+ */
10
+class RecurringInvoiceFactory extends Factory
11
+{
12
+    /**
13
+     * Define the model's default state.
14
+     *
15
+     * @return array<string, mixed>
16
+     */
17
+    public function definition(): array
18
+    {
19
+        return [
20
+            //
21
+        ];
22
+    }
23
+}

+ 65
- 0
database/migrations/2024_11_27_223001_create_recurring_invoices_table.php Parādīt failu

@@ -0,0 +1,65 @@
1
+<?php
2
+
3
+use Illuminate\Database\Migrations\Migration;
4
+use Illuminate\Database\Schema\Blueprint;
5
+use Illuminate\Support\Facades\Schema;
6
+
7
+return new class extends Migration
8
+{
9
+    /**
10
+     * Run the migrations.
11
+     */
12
+    public function up(): void
13
+    {
14
+        Schema::create('recurring_invoices', function (Blueprint $table) {
15
+            $table->id();
16
+            $table->foreignId('company_id')->constrained()->cascadeOnDelete();
17
+            $table->foreignId('client_id')->nullable()->constrained('clients')->nullOnDelete();
18
+            $table->string('logo')->nullable();
19
+            $table->string('header')->nullable();
20
+            $table->string('subheader')->nullable();
21
+            $table->string('order_number')->nullable(); // PO, SO, etc.
22
+            $table->string('payment_terms')->default('due_upon_receipt');
23
+            $table->timestamp('approved_at')->nullable();
24
+            $table->timestamp('ended_at')->nullable();
25
+            $table->string('frequency')->default('monthly'); // daily, weekly, monthly, yearly, custom
26
+            $table->string('interval_type')->nullable(); // for custom frequency "day(s), week(s), month(s), year(s)"
27
+            $table->tinyInteger('interval_value')->nullable(); // "every x" value (only for custom frequency)
28
+            $table->tinyInteger('month')->nullable(); // 1-12 for yearly frequency
29
+            $table->tinyInteger('day_of_month')->nullable(); // 1-31 for monthly, yearly, custom yearly frequency
30
+            $table->tinyInteger('day_of_week')->nullable(); // 1-7 for weekly, custom weekly frequency
31
+            $table->date('start_date')->nullable();
32
+            $table->string('end_type')->default('never'); // never, after, on
33
+            $table->smallInteger('max_occurrences')->nullable(); // when end_type is 'after'
34
+            $table->date('end_date')->nullable(); // when end_type is 'on'
35
+            $table->smallInteger('occurrences_count')->default(0);
36
+            $table->string('timezone')->default(config('app.timezone'));
37
+            $table->date('next_date')->nullable();
38
+            $table->date('last_date')->nullable();
39
+            $table->boolean('auto_send')->default(false);
40
+            $table->time('send_time')->default('09:00:00');
41
+            $table->string('status')->default('draft');
42
+            $table->string('currency_code')->nullable();
43
+            $table->string('discount_method')->default('per_line_item');
44
+            $table->string('discount_computation')->default('percentage');
45
+            $table->integer('discount_rate')->default(0);
46
+            $table->integer('subtotal')->default(0);
47
+            $table->integer('tax_total')->default(0);
48
+            $table->integer('discount_total')->default(0);
49
+            $table->integer('total')->default(0);
50
+            $table->text('terms')->nullable(); // terms, notes
51
+            $table->text('footer')->nullable();
52
+            $table->foreignId('created_by')->nullable()->constrained('users')->nullOnDelete();
53
+            $table->foreignId('updated_by')->nullable()->constrained('users')->nullOnDelete();
54
+            $table->timestamps();
55
+        });
56
+    }
57
+
58
+    /**
59
+     * Reverse the migrations.
60
+     */
61
+    public function down(): void
62
+    {
63
+        Schema::dropIfExists('recurring_invoices');
64
+    }
65
+};

+ 1
- 0
database/migrations/2024_11_27_223015_create_invoices_table.php Parādīt failu

@@ -16,6 +16,7 @@ return new class extends Migration
16 16
             $table->foreignId('company_id')->constrained()->cascadeOnDelete();
17 17
             $table->foreignId('client_id')->nullable()->constrained('clients')->nullOnDelete();
18 18
             $table->foreignId('estimate_id')->nullable()->constrained('estimates')->nullOnDelete();
19
+            $table->foreignId('recurring_invoice_id')->nullable()->constrained('recurring_invoices')->nullOnDelete();
19 20
             $table->string('logo')->nullable();
20 21
             $table->string('header')->nullable();
21 22
             $table->string('subheader')->nullable();

+ 2
- 1
resources/data/lang/en.json Parādīt failu

@@ -216,5 +216,6 @@
216 216
     "Create": "Create",
217 217
     "Estimate Header": "Estimate Header",
218 218
     "Estimate Details": "Estimate Details",
219
-    "Estimate Footer": "Estimate Footer"
219
+    "Estimate Footer": "Estimate Footer",
220
+    "Scheduling": "Scheduling"
220 221
 }

+ 35
- 0
resources/views/filament/forms/components/labeled-field.blade.php Parādīt failu

@@ -0,0 +1,35 @@
1
+@php
2
+    $prefixLabel = $getPrefixLabel();
3
+    $suffixLabel = $getSuffixLabel();
4
+
5
+    $childComponentContainer = $getChildComponentContainer();
6
+    $childComponents = $childComponentContainer->getComponents();
7
+@endphp
8
+
9
+<div
10
+    {{
11
+        $attributes->class([
12
+            'flex items-center gap-x-4',
13
+        ])
14
+    }}
15
+>
16
+    @if($prefixLabel)
17
+        <span class="whitespace-nowrap text-sm font-medium leading-6 text-gray-950 dark:text-white">{{ $prefixLabel }}</span>
18
+    @endif
19
+
20
+    @foreach($childComponents as $component)
21
+        @if(count($component->getChildComponents()) > 1)
22
+            <div>
23
+                {{ $component }}
24
+            </div>
25
+        @else
26
+            <div class="min-w-28 [&_.fi-fo-field-wrp]:m-0 [&_.grid]:!grid-cols-1 [&_.sm\:grid-cols-3]:!grid-cols-1 [&_.sm\:col-span-2]:!col-span-1">
27
+                {{ $component }}
28
+            </div>
29
+        @endif
30
+    @endforeach
31
+
32
+    @if($suffixLabel)
33
+        <span class="whitespace-nowrap text-sm font-medium leading-6 text-gray-950 dark:text-white">{{ $suffixLabel }}</span>
34
+    @endif
35
+</div>

Notiek ielāde…
Atcelt
Saglabāt