Andrew Wallo 9 months ago
parent
commit
9b15af8f50

+ 10
- 2
app/Models/Accounting/RecurringInvoice.php View File

187
 
187
 
188
     public function hasSchedule(): bool
188
     public function hasSchedule(): bool
189
     {
189
     {
190
-        return $this->start_date !== null;
190
+        if (! $this->start_date) {
191
+            return false;
192
+        }
193
+
194
+        if (! $this->wasApproved() && $this->start_date->lt(today())) {
195
+            return false;
196
+        }
197
+
198
+        return true;
191
     }
199
     }
192
 
200
 
193
     public function getScheduleDescription(): string
201
     public function getScheduleDescription(): string
264
     {
272
     {
265
         $lastDate ??= $this->last_date;
273
         $lastDate ??= $this->last_date;
266
 
274
 
267
-        if (! $lastDate && $this->start_date) {
275
+        if (! $lastDate && $this->start_date && $this->wasApproved()) {
268
             return $this->start_date;
276
             return $this->start_date;
269
         }
277
         }
270
 
278
 

+ 4
- 1
app/Observers/RecurringInvoiceObserver.php View File

11
 {
11
 {
12
     public function saving(RecurringInvoice $recurringInvoice): void
12
     public function saving(RecurringInvoice $recurringInvoice): void
13
     {
13
     {
14
-        if (($recurringInvoice->isDirty('start_date') && ! $recurringInvoice->last_date) || $this->otherScheduleDetailsChanged($recurringInvoice)) {
14
+        if (
15
+            $recurringInvoice->wasApproved() &&
16
+            (($recurringInvoice->isDirty('start_date') && ! $recurringInvoice->last_date) || $this->otherScheduleDetailsChanged($recurringInvoice))
17
+        ) {
15
             $recurringInvoice->next_date = $recurringInvoice->calculateNextDate();
18
             $recurringInvoice->next_date = $recurringInvoice->calculateNextDate();
16
         }
19
         }
17
 
20
 

+ 2
- 1
database/factories/Accounting/DocumentLineItemFactory.php View File

6
 use App\Models\Accounting\DocumentLineItem;
6
 use App\Models\Accounting\DocumentLineItem;
7
 use App\Models\Accounting\Estimate;
7
 use App\Models\Accounting\Estimate;
8
 use App\Models\Accounting\Invoice;
8
 use App\Models\Accounting\Invoice;
9
+use App\Models\Accounting\RecurringInvoice;
9
 use App\Models\Common\Offering;
10
 use App\Models\Common\Offering;
10
 use Illuminate\Database\Eloquent\Factories\Factory;
11
 use Illuminate\Database\Eloquent\Factories\Factory;
11
 
12
 
37
         ];
38
         ];
38
     }
39
     }
39
 
40
 
40
-    public function forInvoice(Invoice $invoice): static
41
+    public function forInvoice(Invoice | RecurringInvoice $invoice): static
41
     {
42
     {
42
         return $this
43
         return $this
43
             ->for($invoice, 'documentable')
44
             ->for($invoice, 'documentable')

+ 202
- 31
database/factories/Accounting/RecurringInvoiceFactory.php View File

10
 use App\Enums\Accounting\Month;
10
 use App\Enums\Accounting\Month;
11
 use App\Enums\Accounting\RecurringInvoiceStatus;
11
 use App\Enums\Accounting\RecurringInvoiceStatus;
12
 use App\Enums\Setting\PaymentTerms;
12
 use App\Enums\Setting\PaymentTerms;
13
+use App\Models\Accounting\DocumentLineItem;
13
 use App\Models\Accounting\RecurringInvoice;
14
 use App\Models\Accounting\RecurringInvoice;
14
 use App\Models\Common\Client;
15
 use App\Models\Common\Client;
15
 use Illuminate\Database\Eloquent\Factories\Factory;
16
 use Illuminate\Database\Eloquent\Factories\Factory;
35
         return [
36
         return [
36
             'company_id' => 1,
37
             'company_id' => 1,
37
             'client_id' => Client::inRandomOrder()->value('id'),
38
             'client_id' => Client::inRandomOrder()->value('id'),
39
+            'header' => 'Invoice',
40
+            'subheader' => 'Invoice',
41
+            'order_number' => $this->faker->unique()->numerify('ORD-#####'),
42
+            'payment_terms' => PaymentTerms::Net30,
38
             'status' => RecurringInvoiceStatus::Draft,
43
             'status' => RecurringInvoiceStatus::Draft,
39
-
40
-            // Schedule configuration
41
-            'frequency' => Frequency::Monthly,
42
-            'day_of_month' => DayOfMonth::First,
43
-
44
-            // Date configuration
45
-            'start_date' => now()->addMonth()->startOfMonth(),
46
-            'end_type' => EndType::Never,
47
-
48
-            // Invoice configuration
49
-            'payment_terms' => PaymentTerms::DueUponReceipt,
50
             'currency_code' => 'USD',
44
             'currency_code' => 'USD',
51
-
52
-            // Timestamps and user tracking
53
             'terms' => $this->faker->sentence,
45
             'terms' => $this->faker->sentence,
54
             'footer' => $this->faker->sentence,
46
             'footer' => $this->faker->sentence,
55
             'created_by' => 1,
47
             'created_by' => 1,
57
         ];
49
         ];
58
     }
50
     }
59
 
51
 
60
-    public function weekly(DayOfWeek $dayOfWeek = DayOfWeek::Monday): self
52
+    public function withLineItems(int $count = 3): static
53
+    {
54
+        return $this->afterCreating(function (RecurringInvoice $recurringInvoice) use ($count) {
55
+            DocumentLineItem::factory()
56
+                ->count($count)
57
+                ->forInvoice($recurringInvoice)
58
+                ->create();
59
+
60
+            $this->recalculateTotals($recurringInvoice);
61
+        });
62
+    }
63
+
64
+    public function withSchedule(
65
+        ?Frequency $frequency = null,
66
+        ?Carbon $startDate = null,
67
+        ?EndType $endType = null
68
+    ): static {
69
+        $frequency ??= $this->faker->randomElement(Frequency::class);
70
+        $endType ??= EndType::Never;
71
+
72
+        // Adjust the start date range based on frequency
73
+        $startDate = match ($frequency) {
74
+            Frequency::Daily => Carbon::parse($this->faker->dateTimeBetween('-30 days')), // At most 30 days back
75
+            default => $startDate ?? Carbon::parse($this->faker->dateTimeBetween('-1 year')),
76
+        };
77
+
78
+        return match ($frequency) {
79
+            Frequency::Daily => $this->withDailySchedule($startDate, $endType),
80
+            Frequency::Weekly => $this->withWeeklySchedule($startDate, $endType),
81
+            Frequency::Monthly => $this->withMonthlySchedule($startDate, $endType),
82
+            Frequency::Yearly => $this->withYearlySchedule($startDate, $endType),
83
+            Frequency::Custom => $this->withCustomSchedule($startDate, $endType),
84
+        };
85
+    }
86
+
87
+    protected function withDailySchedule(Carbon $startDate, EndType $endType): static
88
+    {
89
+        return $this->state([
90
+            'frequency' => Frequency::Daily,
91
+            'start_date' => $startDate,
92
+            'end_type' => $endType,
93
+        ]);
94
+    }
95
+
96
+    protected function withWeeklySchedule(Carbon $startDate, EndType $endType): static
61
     {
97
     {
62
         return $this->state([
98
         return $this->state([
63
             'frequency' => Frequency::Weekly,
99
             'frequency' => Frequency::Weekly,
64
-            'day_of_week' => $dayOfWeek,
100
+            'day_of_week' => DayOfWeek::from($startDate->dayOfWeek),
101
+            'start_date' => $startDate,
102
+            'end_type' => $endType,
65
         ]);
103
         ]);
66
     }
104
     }
67
 
105
 
68
-    public function monthly(DayOfMonth $dayOfMonth = DayOfMonth::First): self
106
+    protected function withMonthlySchedule(Carbon $startDate, EndType $endType): static
69
     {
107
     {
70
         return $this->state([
108
         return $this->state([
71
             'frequency' => Frequency::Monthly,
109
             'frequency' => Frequency::Monthly,
72
-            'day_of_month' => $dayOfMonth,
110
+            'day_of_month' => DayOfMonth::from($startDate->day),
111
+            'start_date' => $startDate,
112
+            'end_type' => $endType,
73
         ]);
113
         ]);
74
     }
114
     }
75
 
115
 
76
-    public function yearly(Month $month = Month::January, DayOfMonth $dayOfMonth = DayOfMonth::First): self
116
+    protected function withYearlySchedule(Carbon $startDate, EndType $endType): static
77
     {
117
     {
78
         return $this->state([
118
         return $this->state([
79
             'frequency' => Frequency::Yearly,
119
             'frequency' => Frequency::Yearly,
80
-            'month' => $month,
81
-            'day_of_month' => $dayOfMonth,
120
+            'month' => Month::from($startDate->month),
121
+            'day_of_month' => DayOfMonth::from($startDate->day),
122
+            'start_date' => $startDate,
123
+            'end_type' => $endType,
82
         ]);
124
         ]);
83
     }
125
     }
84
 
126
 
85
-    public function custom(IntervalType $intervalType, int $intervalValue = 1): self
86
-    {
87
-        return $this->state([
127
+    protected function withCustomSchedule(
128
+        Carbon $startDate,
129
+        EndType $endType,
130
+        ?IntervalType $intervalType = null,
131
+        ?int $intervalValue = null
132
+    ): static {
133
+        $intervalType ??= $this->faker->randomElement(IntervalType::class);
134
+        $intervalValue ??= match ($intervalType) {
135
+            IntervalType::Day => $this->faker->numberBetween(1, 7),
136
+            IntervalType::Week => $this->faker->numberBetween(1, 4),
137
+            IntervalType::Month => $this->faker->numberBetween(1, 3),
138
+            IntervalType::Year => 1,
139
+        };
140
+
141
+        $state = [
88
             'frequency' => Frequency::Custom,
142
             'frequency' => Frequency::Custom,
89
             'interval_type' => $intervalType,
143
             'interval_type' => $intervalType,
90
             'interval_value' => $intervalValue,
144
             'interval_value' => $intervalValue,
145
+            'start_date' => $startDate,
146
+            'end_type' => $endType,
147
+        ];
148
+
149
+        // Add interval-specific attributes
150
+        switch ($intervalType) {
151
+            case IntervalType::Day:
152
+                // No additional attributes needed
153
+                break;
154
+
155
+            case IntervalType::Week:
156
+                $state['day_of_week'] = DayOfWeek::from($startDate->dayOfWeek);
157
+
158
+                break;
159
+
160
+            case IntervalType::Month:
161
+                $state['day_of_month'] = DayOfMonth::from($startDate->day);
162
+
163
+                break;
164
+
165
+            case IntervalType::Year:
166
+                $state['month'] = Month::from($startDate->month);
167
+                $state['day_of_month'] = DayOfMonth::from($startDate->day);
168
+
169
+                break;
170
+        }
171
+
172
+        return $this->state($state);
173
+    }
174
+
175
+    public function endAfter(int $occurrences = 12): static
176
+    {
177
+        return $this->state([
178
+            'end_type' => EndType::After,
179
+            'max_occurrences' => $occurrences,
91
         ]);
180
         ]);
92
     }
181
     }
93
 
182
 
94
-    public function withEndDate(Carbon $endDate): self
183
+    public function endOn(?Carbon $endDate = null): static
95
     {
184
     {
185
+        $endDate ??= now()->addMonths($this->faker->numberBetween(1, 12));
186
+
96
         return $this->state([
187
         return $this->state([
97
             'end_type' => EndType::On,
188
             'end_type' => EndType::On,
98
             'end_date' => $endDate,
189
             'end_date' => $endDate,
99
         ]);
190
         ]);
100
     }
191
     }
101
 
192
 
102
-    public function withMaxOccurrences(int $maxOccurrences): self
193
+    public function autoSend(string $sendTime = '09:00'): static
103
     {
194
     {
104
         return $this->state([
195
         return $this->state([
105
-            'end_type' => EndType::After,
106
-            'max_occurrences' => $maxOccurrences,
196
+            'auto_send' => true,
197
+            'send_time' => $sendTime,
107
         ]);
198
         ]);
108
     }
199
     }
109
 
200
 
110
-    public function autoSend(string $time = '09:00'): self
201
+    public function approved(): static
111
     {
202
     {
112
-        return $this->state([
113
-            'auto_send' => true,
114
-            'send_time' => $time,
203
+        return $this->afterCreating(function (RecurringInvoice $recurringInvoice) {
204
+            $this->ensureLineItems($recurringInvoice);
205
+
206
+            if (! $recurringInvoice->hasSchedule()) {
207
+                $this->withSchedule()->callAfterCreating(collect([$recurringInvoice]));
208
+                $recurringInvoice->refresh();
209
+            }
210
+
211
+            if (! $recurringInvoice->canBeApproved()) {
212
+                return;
213
+            }
214
+
215
+            $approvedAt = $recurringInvoice->start_date
216
+                ? $recurringInvoice->start_date->copy()->subDays($this->faker->numberBetween(1, 7))
217
+                : now()->subDays($this->faker->numberBetween(1, 30));
218
+
219
+            $recurringInvoice->approveDraft($approvedAt);
220
+        });
221
+    }
222
+
223
+    public function active(): static
224
+    {
225
+        return $this->withLineItems()
226
+            ->withSchedule()
227
+            ->approved();
228
+    }
229
+
230
+    public function ended(): static
231
+    {
232
+        return $this->afterCreating(function (RecurringInvoice $recurringInvoice) {
233
+            if (! $recurringInvoice->canBeEnded()) {
234
+                $this->active()->callAfterCreating(collect([$recurringInvoice]));
235
+            }
236
+
237
+            $endedAt = now()->subDays($this->faker->numberBetween(1, 30));
238
+
239
+            $recurringInvoice->update([
240
+                'ended_at' => $endedAt,
241
+                'status' => RecurringInvoiceStatus::Ended,
242
+            ]);
243
+        });
244
+    }
245
+
246
+    public function configure(): static
247
+    {
248
+        return $this->afterCreating(function (RecurringInvoice $recurringInvoice) {
249
+            $this->ensureLineItems($recurringInvoice);
250
+
251
+            $nextDate = $recurringInvoice->calculateNextDate();
252
+
253
+            if ($nextDate) {
254
+                $recurringInvoice->updateQuietly([
255
+                    'next_date' => $nextDate,
256
+                ]);
257
+            }
258
+        });
259
+    }
260
+
261
+    protected function ensureLineItems(RecurringInvoice $recurringInvoice): void
262
+    {
263
+        if (! $recurringInvoice->hasLineItems()) {
264
+            $this->withLineItems()->callAfterCreating(collect([$recurringInvoice]));
265
+        }
266
+    }
267
+
268
+    protected function recalculateTotals(RecurringInvoice $recurringInvoice): void
269
+    {
270
+        $recurringInvoice->refresh();
271
+
272
+        if (! $recurringInvoice->hasLineItems()) {
273
+            return;
274
+        }
275
+
276
+        $subtotal = $recurringInvoice->lineItems()->sum('subtotal') / 100;
277
+        $taxTotal = $recurringInvoice->lineItems()->sum('tax_total') / 100;
278
+        $discountTotal = $recurringInvoice->lineItems()->sum('discount_total') / 100;
279
+        $grandTotal = $subtotal + $taxTotal - $discountTotal;
280
+
281
+        $recurringInvoice->update([
282
+            'subtotal' => $subtotal,
283
+            'tax_total' => $taxTotal,
284
+            'discount_total' => $discountTotal,
285
+            'total' => $grandTotal,
115
         ]);
286
         ]);
116
     }
287
     }
117
 }
288
 }

+ 81
- 0
database/factories/CompanyFactory.php View File

2
 
2
 
3
 namespace Database\Factories;
3
 namespace Database\Factories;
4
 
4
 
5
+use App\Enums\Accounting\Frequency;
5
 use App\Models\Accounting\Bill;
6
 use App\Models\Accounting\Bill;
6
 use App\Models\Accounting\Estimate;
7
 use App\Models\Accounting\Estimate;
7
 use App\Models\Accounting\Invoice;
8
 use App\Models\Accounting\Invoice;
9
+use App\Models\Accounting\RecurringInvoice;
8
 use App\Models\Accounting\Transaction;
10
 use App\Models\Accounting\Transaction;
9
 use App\Models\Common\Client;
11
 use App\Models\Common\Client;
10
 use App\Models\Common\Offering;
12
 use App\Models\Common\Offering;
164
         });
166
         });
165
     }
167
     }
166
 
168
 
169
+    public function withRecurringInvoices(int $count = 10): self
170
+    {
171
+        return $this->afterCreating(function (Company $company) use ($count) {
172
+            $draftCount = (int) floor($count * 0.2);     // 20% drafts without schedule
173
+            $scheduledCount = (int) floor($count * 0.2);  // 20% drafts with schedule
174
+            $activeCount = (int) floor($count * 0.4);     // 40% active and generating
175
+            $endedCount = (int) floor($count * 0.1);      // 10% manually ended
176
+            $completedCount = $count - ($draftCount + $scheduledCount + $activeCount + $endedCount); // 10% completed by end conditions
177
+
178
+            // Draft recurring invoices (no schedule)
179
+            RecurringInvoice::factory()
180
+                ->count($draftCount)
181
+                ->withLineItems()
182
+                ->create([
183
+                    'company_id' => $company->id,
184
+                    'created_by' => $company->user_id,
185
+                    'updated_by' => $company->user_id,
186
+                ]);
187
+
188
+            // Draft recurring invoices with schedule
189
+            RecurringInvoice::factory()
190
+                ->count($scheduledCount)
191
+                ->withLineItems()
192
+                ->withSchedule()
193
+                ->create([
194
+                    'company_id' => $company->id,
195
+                    'created_by' => $company->user_id,
196
+                    'updated_by' => $company->user_id,
197
+                ]);
198
+
199
+            // Active recurring invoices with various schedules and historical invoices
200
+            $frequencies = [
201
+                Frequency::Daily,
202
+                Frequency::Weekly,
203
+                Frequency::Monthly,
204
+                Frequency::Yearly,
205
+                Frequency::Custom,
206
+            ];
207
+
208
+            foreach (array_chunk(range(1, $activeCount), (int) ceil($activeCount / count($frequencies))) as $chunk) {
209
+                RecurringInvoice::factory()
210
+                    ->count(count($chunk))
211
+                    ->withLineItems()
212
+                    ->withSchedule(fake()->randomElement($frequencies)) // Randomize frequency
213
+                    ->active()
214
+                    ->create([
215
+                        'company_id' => $company->id,
216
+                        'created_by' => $company->user_id,
217
+                        'updated_by' => $company->user_id,
218
+                    ]);
219
+            }
220
+
221
+            // Manually ended recurring invoices
222
+            RecurringInvoice::factory()
223
+                ->count($endedCount)
224
+                ->withLineItems()
225
+                ->withSchedule()
226
+                ->ended()
227
+                ->create([
228
+                    'company_id' => $company->id,
229
+                    'created_by' => $company->user_id,
230
+                    'updated_by' => $company->user_id,
231
+                ]);
232
+
233
+            // Completed recurring invoices (reached end conditions)
234
+            RecurringInvoice::factory()
235
+                ->count($completedCount)
236
+                ->withLineItems()
237
+                ->withSchedule()
238
+                ->endAfter($this->faker->numberBetween(5, 12))
239
+                ->active()
240
+                ->create([
241
+                    'company_id' => $company->id,
242
+                    'created_by' => $company->user_id,
243
+                    'updated_by' => $company->user_id,
244
+                ]);
245
+        });
246
+    }
247
+
167
     public function withEstimates(int $count = 10): self
248
     public function withEstimates(int $count = 10): self
168
     {
249
     {
169
         return $this->afterCreating(function (Company $company) use ($count) {
250
         return $this->afterCreating(function (Company $company) use ($count) {

+ 1
- 0
database/seeders/DatabaseSeeder.php View File

25
                     ->withClients()
25
                     ->withClients()
26
                     ->withVendors()
26
                     ->withVendors()
27
                     ->withInvoices(50)
27
                     ->withInvoices(50)
28
+                    ->withRecurringInvoices()
28
                     ->withEstimates(50)
29
                     ->withEstimates(50)
29
                     ->withBills(50);
30
                     ->withBills(50);
30
             })
31
             })

Loading…
Cancel
Save