Andrew Wallo 9 kuukautta sitten
vanhempi
commit
7ae44f39ae

+ 2
- 1
app/Enums/Accounting/InvoiceStatus.php Näytä tiedosto

@@ -10,6 +10,7 @@ enum InvoiceStatus: string implements HasColor, HasLabel
10 10
     case Draft = 'draft';
11 11
     case Unsent = 'unsent';
12 12
     case Sent = 'sent';
13
+    case Viewed = 'viewed';
13 14
 
14 15
     case Partial = 'partial';
15 16
 
@@ -30,7 +31,7 @@ enum InvoiceStatus: string implements HasColor, HasLabel
30 31
     {
31 32
         return match ($this) {
32 33
             self::Draft, self::Unsent, self::Void => 'gray',
33
-            self::Sent => 'primary',
34
+            self::Sent, self::Viewed => 'primary',
34 35
             self::Partial => 'warning',
35 36
             self::Paid, self::Overpaid => 'success',
36 37
             self::Overdue => 'danger',

+ 2
- 2
app/Filament/Company/Resources/Sales/EstimateResource.php Näytä tiedosto

@@ -449,7 +449,7 @@ class EstimateResource extends Resource
449 449
                         ->successNotificationTitle('Estimates Accepted')
450 450
                         ->failureNotificationTitle('Failed to Mark Estimates as Accepted')
451 451
                         ->before(function (Collection $records, Tables\Actions\BulkAction $action) {
452
-                            $doesntContainSent = $records->contains(fn (Estimate $record) => $record->status !== EstimateStatus::Sent || $record->isAccepted());
452
+                            $doesntContainSent = $records->contains(fn (Estimate $record) => $record->status !== EstimateStatus::Sent || $record->wasAccepted());
453 453
 
454 454
                             if ($doesntContainSent) {
455 455
                                 Notification::make()
@@ -480,7 +480,7 @@ class EstimateResource extends Resource
480 480
                         ->successNotificationTitle('Estimates Declined')
481 481
                         ->failureNotificationTitle('Failed to Mark Estimates as Declined')
482 482
                         ->before(function (Collection $records, Tables\Actions\BulkAction $action) {
483
-                            $doesntContainSent = $records->contains(fn (Estimate $record) => $record->status !== EstimateStatus::Sent || $record->isDeclined());
483
+                            $doesntContainSent = $records->contains(fn (Estimate $record) => $record->status !== EstimateStatus::Sent || $record->wasDeclined());
484 484
 
485 485
                             if ($doesntContainSent) {
486 486
                                 Notification::make()

+ 16
- 1
app/Models/Accounting/Bill.php Näytä tiedosto

@@ -32,8 +32,8 @@ use Illuminate\Database\Eloquent\Relations\MorphMany;
32 32
 use Illuminate\Database\Eloquent\Relations\MorphOne;
33 33
 use Illuminate\Support\Carbon;
34 34
 
35
-#[ObservedBy(BillObserver::class)]
36 35
 #[CollectedBy(DocumentCollection::class)]
36
+#[ObservedBy(BillObserver::class)]
37 37
 class Bill extends Model
38 38
 {
39 39
     use Blamable;
@@ -129,6 +129,16 @@ class Bill extends Model
129 129
         });
130 130
     }
131 131
 
132
+    public function wasInitialized(): bool
133
+    {
134
+        return $this->hasInitialTransaction();
135
+    }
136
+
137
+    public function isPaid(): bool
138
+    {
139
+        return $this->paid_at !== null;
140
+    }
141
+
132 142
     public function canBeOverdue(): bool
133 143
     {
134 144
         return in_array($this->status, BillStatus::canBeOverdue());
@@ -142,6 +152,11 @@ class Bill extends Model
142 152
         ]) && $this->currency_code === CurrencyAccessor::getDefaultCurrency();
143 153
     }
144 154
 
155
+    public function hasLineItems(): bool
156
+    {
157
+        return $this->lineItems()->exists();
158
+    }
159
+
145 160
     public function hasPayments(): bool
146 161
     {
147 162
         return $this->payments->isNotEmpty();

+ 59
- 9
app/Models/Accounting/Estimate.php Näytä tiedosto

@@ -119,26 +119,36 @@ class Estimate extends Model
119 119
         return $this->status === EstimateStatus::Draft;
120 120
     }
121 121
 
122
-    public function isApproved(): bool
122
+    public function wasApproved(): bool
123 123
     {
124 124
         return $this->approved_at !== null;
125 125
     }
126 126
 
127
-    public function isAccepted(): bool
127
+    public function wasAccepted(): bool
128 128
     {
129 129
         return $this->accepted_at !== null;
130 130
     }
131 131
 
132
-    public function isDeclined(): bool
132
+    public function wasDeclined(): bool
133 133
     {
134 134
         return $this->declined_at !== null;
135 135
     }
136 136
 
137
-    public function isSent(): bool
137
+    public function wasConverted(): bool
138
+    {
139
+        return $this->converted_at !== null;
140
+    }
141
+
142
+    public function hasBeenSent(): bool
138 143
     {
139 144
         return $this->last_sent_at !== null;
140 145
     }
141 146
 
147
+    public function hasBeenViewed(): bool
148
+    {
149
+        return $this->last_viewed_at !== null;
150
+    }
151
+
142 152
     public function canBeExpired(): bool
143 153
     {
144 154
         return ! in_array($this->status, [
@@ -149,6 +159,36 @@ class Estimate extends Model
149 159
         ]);
150 160
     }
151 161
 
162
+    public function canBeApproved(): bool
163
+    {
164
+        return $this->isDraft() && ! $this->wasApproved();
165
+    }
166
+
167
+    public function canBeConverted(): bool
168
+    {
169
+        return $this->wasAccepted() && ! $this->wasConverted();
170
+    }
171
+
172
+    public function canBeMarkedAsDeclined(): bool
173
+    {
174
+        return $this->hasBeenSent() && ! $this->wasDeclined();
175
+    }
176
+
177
+    public function canBeMarkedAsSent(): bool
178
+    {
179
+        return ! $this->hasBeenSent();
180
+    }
181
+
182
+    public function canBeMarkedAsAccepted(): bool
183
+    {
184
+        return $this->hasBeenSent() && ! $this->wasAccepted();
185
+    }
186
+
187
+    public function hasLineItems(): bool
188
+    {
189
+        return $this->lineItems()->exists();
190
+    }
191
+
152 192
     public function scopeActive(Builder $query): Builder
153 193
     {
154 194
         return $query->whereIn('status', [
@@ -212,7 +252,7 @@ class Estimate extends Model
212 252
             ->label('Approve')
213 253
             ->icon('heroicon-o-check-circle')
214 254
             ->visible(function (self $record) {
215
-                return $record->isDraft();
255
+                return $record->canBeApproved();
216 256
             })
217 257
             ->databaseTransaction()
218 258
             ->successNotificationTitle('Estimate Approved')
@@ -229,7 +269,7 @@ class Estimate extends Model
229 269
             ->label('Mark as Sent')
230 270
             ->icon('heroicon-o-paper-airplane')
231 271
             ->visible(static function (self $record) {
232
-                return ! $record->isSent();
272
+                return $record->canBeMarkedAsSent();
233 273
             })
234 274
             ->successNotificationTitle('Estimate Sent')
235 275
             ->action(function (self $record, MountableAction $action) {
@@ -249,6 +289,16 @@ class Estimate extends Model
249 289
         ]);
250 290
     }
251 291
 
292
+    public function markAsViewed(?Carbon $viewedAt = null): void
293
+    {
294
+        $viewedAt ??= now();
295
+
296
+        $this->update([
297
+            'status' => EstimateStatus::Viewed,
298
+            'last_viewed_at' => $viewedAt,
299
+        ]);
300
+    }
301
+
252 302
     public static function getReplicateAction(string $action = ReplicateAction::class): MountableAction
253 303
     {
254 304
         return $action::make()
@@ -289,7 +339,7 @@ class Estimate extends Model
289 339
             ->label('Mark as Accepted')
290 340
             ->icon('heroicon-o-check-badge')
291 341
             ->visible(static function (self $record) {
292
-                return $record->isSent() && ! $record->isAccepted();
342
+                return $record->canBeMarkedAsAccepted();
293 343
             })
294 344
             ->databaseTransaction()
295 345
             ->successNotificationTitle('Estimate Accepted')
@@ -316,7 +366,7 @@ class Estimate extends Model
316 366
             ->label('Mark as Declined')
317 367
             ->icon('heroicon-o-x-circle')
318 368
             ->visible(static function (self $record) {
319
-                return $record->isSent() && ! $record->isDeclined();
369
+                return $record->canBeMarkedAsDeclined();
320 370
             })
321 371
             ->color('danger')
322 372
             ->requiresConfirmation()
@@ -345,7 +395,7 @@ class Estimate extends Model
345 395
             ->label('Convert to Invoice')
346 396
             ->icon('heroicon-o-arrow-right-on-rectangle')
347 397
             ->visible(static function (self $record) {
348
-                return $record->status === EstimateStatus::Accepted && ! $record->invoice;
398
+                return $record->canBeConverted();
349 399
             })
350 400
             ->databaseTransaction()
351 401
             ->successNotificationTitle('Estimate Converted to Invoice')

+ 73
- 22
app/Models/Accounting/Invoice.php Näytä tiedosto

@@ -58,6 +58,7 @@ class Invoice extends Model
58 58
         'approved_at',
59 59
         'paid_at',
60 60
         'last_sent_at',
61
+        'last_viewed_at',
61 62
         'status',
62 63
         'currency_code',
63 64
         'discount_method',
@@ -80,6 +81,7 @@ class Invoice extends Model
80 81
         'approved_at' => 'datetime',
81 82
         'paid_at' => 'datetime',
82 83
         'last_sent_at' => 'datetime',
84
+        'last_viewed_at' => 'datetime',
83 85
         'status' => InvoiceStatus::class,
84 86
         'discount_method' => DocumentDiscountMethod::class,
85 87
         'discount_computation' => AdjustmentComputation::class,
@@ -160,6 +162,26 @@ class Invoice extends Model
160 162
         return $this->status === InvoiceStatus::Draft;
161 163
     }
162 164
 
165
+    public function wasApproved(): bool
166
+    {
167
+        return $this->approved_at !== null;
168
+    }
169
+
170
+    public function isPaid(): bool
171
+    {
172
+        return $this->paid_at !== null;
173
+    }
174
+
175
+    public function hasBeenSent(): bool
176
+    {
177
+        return $this->last_sent_at !== null;
178
+    }
179
+
180
+    public function hasBeenViewed(): bool
181
+    {
182
+        return $this->last_viewed_at !== null;
183
+    }
184
+
163 185
     public function canRecordPayment(): bool
164 186
     {
165 187
         return ! in_array($this->status, [
@@ -184,9 +206,24 @@ class Invoice extends Model
184 206
         return in_array($this->status, InvoiceStatus::canBeOverdue());
185 207
     }
186 208
 
209
+    public function canBeApproved(): bool
210
+    {
211
+        return $this->isDraft() && ! $this->wasApproved();
212
+    }
213
+
214
+    public function canBeMarkedAsSent(): bool
215
+    {
216
+        return ! $this->hasBeenSent();
217
+    }
218
+
219
+    public function hasLineItems(): bool
220
+    {
221
+        return $this->lineItems()->exists();
222
+    }
223
+
187 224
     public function hasPayments(): bool
188 225
     {
189
-        return $this->payments->isNotEmpty();
226
+        return $this->payments()->exists();
190 227
     }
191 228
 
192 229
     public static function getNextDocumentNumber(?Company $company = null): string
@@ -398,7 +435,7 @@ class Invoice extends Model
398 435
             ->label('Approve')
399 436
             ->icon('heroicon-o-check-circle')
400 437
             ->visible(function (self $record) {
401
-                return $record->isDraft();
438
+                return $record->canBeApproved();
402 439
             })
403 440
             ->databaseTransaction()
404 441
             ->successNotificationTitle('Invoice Approved')
@@ -415,7 +452,7 @@ class Invoice extends Model
415 452
             ->label('Mark as Sent')
416 453
             ->icon('heroicon-o-paper-airplane')
417 454
             ->visible(static function (self $record) {
418
-                return ! $record->last_sent_at;
455
+                return $record->canBeMarkedAsSent();
419 456
             })
420 457
             ->successNotificationTitle('Invoice Sent')
421 458
             ->action(function (self $record, MountableAction $action) {
@@ -435,6 +472,16 @@ class Invoice extends Model
435 472
         ]);
436 473
     }
437 474
 
475
+    public function markAsViewed(?Carbon $viewedAt = null): void
476
+    {
477
+        $viewedAt ??= now();
478
+
479
+        $this->update([
480
+            'status' => InvoiceStatus::Viewed,
481
+            'last_viewed_at' => $viewedAt,
482
+        ]);
483
+    }
484
+
438 485
     public static function getReplicateAction(string $action = ReplicateAction::class): MountableAction
439 486
     {
440 487
         return $action::make()
@@ -462,28 +509,32 @@ class Invoice extends Model
462 509
             })
463 510
             ->databaseTransaction()
464 511
             ->after(function (self $original, self $replica) {
465
-                $original->lineItems->each(function (DocumentLineItem $lineItem) use ($replica) {
466
-                    $replicaLineItem = $lineItem->replicate([
467
-                        'documentable_id',
468
-                        'documentable_type',
469
-                        'subtotal',
470
-                        'total',
471
-                        'created_by',
472
-                        'updated_by',
473
-                        'created_at',
474
-                        'updated_at',
475
-                    ]);
476
-
477
-                    $replicaLineItem->documentable_id = $replica->id;
478
-                    $replicaLineItem->documentable_type = $replica->getMorphClass();
479
-
480
-                    $replicaLineItem->save();
481
-
482
-                    $replicaLineItem->adjustments()->sync($lineItem->adjustments->pluck('id'));
483
-                });
512
+                $original->replicateLineItems($replica);
484 513
             })
485 514
             ->successRedirectUrl(static function (self $replica) {
486 515
                 return InvoiceResource::getUrl('edit', ['record' => $replica]);
487 516
             });
488 517
     }
518
+
519
+    public function replicateLineItems(Model $target): void
520
+    {
521
+        $this->lineItems->each(function (DocumentLineItem $lineItem) use ($target) {
522
+            $replica = $lineItem->replicate([
523
+                'documentable_id',
524
+                'documentable_type',
525
+                'subtotal',
526
+                'total',
527
+                'created_by',
528
+                'updated_by',
529
+                'created_at',
530
+                'updated_at',
531
+            ]);
532
+
533
+            $replica->documentable_id = $target->id;
534
+            $replica->documentable_type = $target->getMorphClass();
535
+            $replica->save();
536
+
537
+            $replica->adjustments()->sync($lineItem->adjustments->pluck('id'));
538
+        });
539
+    }
489 540
 }

+ 1
- 2
app/Policies/EstimatePolicy.php Näytä tiedosto

@@ -2,7 +2,6 @@
2 2
 
3 3
 namespace App\Policies;
4 4
 
5
-use App\Enums\Accounting\EstimateStatus;
6 5
 use App\Models\Accounting\Estimate;
7 6
 use App\Models\User;
8 7
 
@@ -37,7 +36,7 @@ class EstimatePolicy
37 36
      */
38 37
     public function update(User $user, Estimate $estimate): bool
39 38
     {
40
-        if ($estimate->status === EstimateStatus::Converted) {
39
+        if ($estimate->wasConverted()) {
41 40
             return false;
42 41
         }
43 42
 

+ 6
- 6
composer.lock Näytä tiedosto

@@ -497,16 +497,16 @@
497 497
         },
498 498
         {
499 499
             "name": "aws/aws-sdk-php",
500
-            "version": "3.336.4",
500
+            "version": "3.336.6",
501 501
             "source": {
502 502
                 "type": "git",
503 503
                 "url": "https://github.com/aws/aws-sdk-php.git",
504
-                "reference": "5bc056d14a8bad0e9ec5a6ce03e46d8e4329b7fc"
504
+                "reference": "0a99dab427f0a1c082775301141aeac3558691ad"
505 505
             },
506 506
             "dist": {
507 507
                 "type": "zip",
508
-                "url": "https://api.github.com/repos/aws/aws-sdk-php/zipball/5bc056d14a8bad0e9ec5a6ce03e46d8e4329b7fc",
509
-                "reference": "5bc056d14a8bad0e9ec5a6ce03e46d8e4329b7fc",
508
+                "url": "https://api.github.com/repos/aws/aws-sdk-php/zipball/0a99dab427f0a1c082775301141aeac3558691ad",
509
+                "reference": "0a99dab427f0a1c082775301141aeac3558691ad",
510 510
                 "shasum": ""
511 511
             },
512 512
             "require": {
@@ -589,9 +589,9 @@
589 589
             "support": {
590 590
                 "forum": "https://forums.aws.amazon.com/forum.jspa?forumID=80",
591 591
                 "issues": "https://github.com/aws/aws-sdk-php/issues",
592
-                "source": "https://github.com/aws/aws-sdk-php/tree/3.336.4"
592
+                "source": "https://github.com/aws/aws-sdk-php/tree/3.336.6"
593 593
             },
594
-            "time": "2024-12-26T19:13:21+00:00"
594
+            "time": "2024-12-28T04:16:13+00:00"
595 595
         },
596 596
         {
597 597
             "name": "aws/aws-sdk-php-laravel",

+ 94
- 43
database/factories/Accounting/BillFactory.php Näytä tiedosto

@@ -29,14 +29,11 @@ class BillFactory extends Factory
29 29
      */
30 30
     public function definition(): array
31 31
     {
32
-        // 50% chance of being a future bill
33 32
         $isFutureBill = $this->faker->boolean();
34 33
 
35 34
         if ($isFutureBill) {
36
-            // For future bills, date is recent and due date is in near future
37 35
             $billDate = $this->faker->dateTimeBetween('-10 days', '+10 days');
38 36
         } else {
39
-            // For past bills, both date and due date are in the past
40 37
             $billDate = $this->faker->dateTimeBetween('-1 year', '-30 days');
41 38
         }
42 39
 
@@ -57,44 +54,80 @@ class BillFactory extends Factory
57 54
         ];
58 55
     }
59 56
 
60
-    public function withLineItems(int $count = 3): self
57
+    public function withLineItems(int $count = 3): static
61 58
     {
62
-        return $this->has(DocumentLineItem::factory()->forBill()->count($count), 'lineItems');
59
+        return $this->afterCreating(function (Bill $bill) use ($count) {
60
+            DocumentLineItem::factory()
61
+                ->count($count)
62
+                ->forBill($bill)
63
+                ->create();
64
+
65
+            $this->recalculateTotals($bill);
66
+        });
63 67
     }
64 68
 
65 69
     public function initialized(): static
66 70
     {
67 71
         return $this->afterCreating(function (Bill $bill) {
68
-            if ($bill->hasInitialTransaction()) {
72
+            $this->ensureLineItems($bill);
73
+
74
+            if ($bill->wasInitialized()) {
69 75
                 return;
70 76
             }
71 77
 
72
-            $this->recalculateTotals($bill);
73
-
74
-            $postedAt = Carbon::parse($bill->date)->addHours($this->faker->numberBetween(1, 24));
78
+            $postedAt = Carbon::parse($bill->date)
79
+                ->addHours($this->faker->numberBetween(1, 24));
75 80
 
76 81
             $bill->createInitialTransaction($postedAt);
77 82
         });
78 83
     }
79 84
 
80
-    public function withPayments(?int $min = 1, ?int $max = null, BillStatus $billStatus = BillStatus::Paid): static
85
+    public function partial(int $maxPayments = 4): static
81 86
     {
82
-        return $this->afterCreating(function (Bill $bill) use ($billStatus, $max, $min) {
83
-            if (! $bill->hasInitialTransaction()) {
84
-                $this->recalculateTotals($bill);
87
+        return $this->afterCreating(function (Bill $bill) use ($maxPayments) {
88
+            $this->ensureInitialized($bill);
85 89
 
86
-                $postedAt = Carbon::parse($bill->date)->addHours($this->faker->numberBetween(1, 24));
90
+            $this->withPayments(max: $maxPayments, billStatus: BillStatus::Partial)
91
+                ->callAfterCreating(collect([$bill]));
92
+        });
93
+    }
87 94
 
88
-                $bill->createInitialTransaction($postedAt);
89
-            }
95
+    public function paid(int $maxPayments = 4): static
96
+    {
97
+        return $this->afterCreating(function (Bill $bill) use ($maxPayments) {
98
+            $this->ensureInitialized($bill);
99
+
100
+            $this->withPayments(max: $maxPayments)
101
+                ->callAfterCreating(collect([$bill]));
102
+        });
103
+    }
104
+
105
+    public function overdue(): static
106
+    {
107
+        return $this
108
+            ->state([
109
+                'due_date' => now()->subDays($this->faker->numberBetween(1, 30)),
110
+            ])
111
+            ->afterCreating(function (Bill $bill) {
112
+                $this->ensureInitialized($bill);
113
+            });
114
+    }
115
+
116
+    public function withPayments(?int $min = null, ?int $max = null, BillStatus $billStatus = BillStatus::Paid): static
117
+    {
118
+        $min ??= 1;
119
+
120
+        return $this->afterCreating(function (Bill $bill) use ($billStatus, $max, $min) {
121
+            $this->ensureInitialized($bill);
90 122
 
91 123
             $bill->refresh();
92 124
 
93
-            $totalAmountDue = $bill->getRawOriginal('amount_due');
125
+            $amountDue = $bill->getRawOriginal('amount_due');
94 126
 
95
-            if ($billStatus === BillStatus::Partial) {
96
-                $totalAmountDue = (int) floor($totalAmountDue * 0.5);
97
-            }
127
+            $totalAmountDue = match ($billStatus) {
128
+                BillStatus::Partial => (int) floor($amountDue * 0.5),
129
+                default => $amountDue,
130
+            };
98 131
 
99 132
             if ($totalAmountDue <= 0 || empty($totalAmountDue)) {
100 133
                 return;
@@ -129,19 +162,23 @@ class BillFactory extends Factory
129 162
                 $remainingAmount -= $amount;
130 163
             }
131 164
 
132
-            if ($billStatus === BillStatus::Paid) {
133
-                $latestPaymentDate = max($paymentDates);
134
-                $bill->updateQuietly([
135
-                    'status' => $billStatus,
136
-                    'paid_at' => $latestPaymentDate,
137
-                ]);
165
+            if ($billStatus !== BillStatus::Paid) {
166
+                return;
138 167
             }
168
+
169
+            $latestPaymentDate = max($paymentDates);
170
+            $bill->updateQuietly([
171
+                'status' => $billStatus,
172
+                'paid_at' => $latestPaymentDate,
173
+            ]);
139 174
         });
140 175
     }
141 176
 
142 177
     public function configure(): static
143 178
     {
144 179
         return $this->afterCreating(function (Bill $bill) {
180
+            $this->ensureInitialized($bill);
181
+
145 182
             $paddedId = str_pad((string) $bill->id, 5, '0', STR_PAD_LEFT);
146 183
 
147 184
             $bill->updateQuietly([
@@ -149,10 +186,7 @@ class BillFactory extends Factory
149 186
                 'order_number' => "PO-{$paddedId}",
150 187
             ]);
151 188
 
152
-            $this->recalculateTotals($bill);
153
-
154
-            // Check for overdue status
155
-            if ($bill->due_date < today() && $bill->status !== BillStatus::Paid) {
189
+            if ($bill->wasInitialized() && $bill->is_currently_overdue) {
156 190
                 $bill->updateQuietly([
157 191
                     'status' => BillStatus::Overdue,
158 192
                 ]);
@@ -160,21 +194,38 @@ class BillFactory extends Factory
160 194
         });
161 195
     }
162 196
 
197
+    protected function ensureLineItems(Bill $bill): void
198
+    {
199
+        if (! $bill->hasLineItems()) {
200
+            $this->withLineItems()->callAfterCreating(collect([$bill]));
201
+        }
202
+    }
203
+
204
+    protected function ensureInitialized(Bill $bill): void
205
+    {
206
+        if (! $bill->wasInitialized()) {
207
+            $this->initialized()->callAfterCreating(collect([$bill]));
208
+        }
209
+    }
210
+
163 211
     protected function recalculateTotals(Bill $bill): void
164 212
     {
165
-        if ($bill->lineItems()->exists()) {
166
-            $bill->refresh();
167
-            $subtotal = $bill->lineItems()->sum('subtotal') / 100;
168
-            $taxTotal = $bill->lineItems()->sum('tax_total') / 100;
169
-            $discountTotal = $bill->lineItems()->sum('discount_total') / 100;
170
-            $grandTotal = $subtotal + $taxTotal - $discountTotal;
171
-
172
-            $bill->update([
173
-                'subtotal' => $subtotal,
174
-                'tax_total' => $taxTotal,
175
-                'discount_total' => $discountTotal,
176
-                'total' => $grandTotal,
177
-            ]);
213
+        $bill->refresh();
214
+
215
+        if (! $bill->hasLineItems()) {
216
+            return;
178 217
         }
218
+
219
+        $subtotal = $bill->lineItems()->sum('subtotal') / 100;
220
+        $taxTotal = $bill->lineItems()->sum('tax_total') / 100;
221
+        $discountTotal = $bill->lineItems()->sum('discount_total') / 100;
222
+        $grandTotal = $subtotal + $taxTotal - $discountTotal;
223
+
224
+        $bill->update([
225
+            'subtotal' => $subtotal,
226
+            'tax_total' => $taxTotal,
227
+            'discount_total' => $discountTotal,
228
+            'total' => $grandTotal,
229
+        ]);
179 230
     }
180 231
 }

+ 96
- 84
database/factories/Accounting/DocumentLineItemFactory.php Näytä tiedosto

@@ -2,7 +2,10 @@
2 2
 
3 3
 namespace Database\Factories\Accounting;
4 4
 
5
+use App\Models\Accounting\Bill;
5 6
 use App\Models\Accounting\DocumentLineItem;
7
+use App\Models\Accounting\Estimate;
8
+use App\Models\Accounting\Invoice;
6 9
 use App\Models\Common\Offering;
7 10
 use Illuminate\Database\Eloquent\Factories\Factory;
8 11
 
@@ -34,96 +37,105 @@ class DocumentLineItemFactory extends Factory
34 37
         ];
35 38
     }
36 39
 
37
-    public function forInvoice(): static
40
+    public function forInvoice(Invoice $invoice): static
38 41
     {
39
-        return $this->state(function (array $attributes) {
40
-            $offering = Offering::where('sellable', true)
41
-                ->inRandomOrder()
42
-                ->first();
43
-
44
-            return [
45
-                'offering_id' => $offering->id,
46
-                'unit_price' => $offering->price,
47
-            ];
48
-        })->afterCreating(function (DocumentLineItem $lineItem) {
49
-            $offering = $lineItem->offering;
50
-
51
-            if ($offering) {
52
-                $lineItem->salesTaxes()->syncWithoutDetaching($offering->salesTaxes->pluck('id')->toArray());
53
-                $lineItem->salesDiscounts()->syncWithoutDetaching($offering->salesDiscounts->pluck('id')->toArray());
54
-            }
55
-
56
-            $lineItem->refresh();
57
-
58
-            $taxTotal = $lineItem->calculateTaxTotal()->getAmount();
59
-            $discountTotal = $lineItem->calculateDiscountTotal()->getAmount();
60
-
61
-            $lineItem->updateQuietly([
62
-                'tax_total' => $taxTotal,
63
-                'discount_total' => $discountTotal,
64
-            ]);
65
-        });
42
+        return $this
43
+            ->for($invoice, 'documentable')
44
+            ->state(function (array $attributes) {
45
+                $offering = Offering::where('sellable', true)
46
+                    ->inRandomOrder()
47
+                    ->first();
48
+
49
+                return [
50
+                    'offering_id' => $offering->id,
51
+                    'unit_price' => $offering->price,
52
+                ];
53
+            })
54
+            ->afterCreating(function (DocumentLineItem $lineItem) {
55
+                $offering = $lineItem->offering;
56
+
57
+                if ($offering) {
58
+                    $lineItem->salesTaxes()->syncWithoutDetaching($offering->salesTaxes->pluck('id')->toArray());
59
+                    $lineItem->salesDiscounts()->syncWithoutDetaching($offering->salesDiscounts->pluck('id')->toArray());
60
+                }
61
+
62
+                $lineItem->refresh();
63
+
64
+                $taxTotal = $lineItem->calculateTaxTotal()->getAmount();
65
+                $discountTotal = $lineItem->calculateDiscountTotal()->getAmount();
66
+
67
+                $lineItem->updateQuietly([
68
+                    'tax_total' => $taxTotal,
69
+                    'discount_total' => $discountTotal,
70
+                ]);
71
+            });
66 72
     }
67 73
 
68
-    public function forEstimate(): static
74
+    public function forEstimate(Estimate $estimate): static
69 75
     {
70
-        return $this->state(function (array $attributes) {
71
-            $offering = Offering::where('sellable', true)
72
-                ->inRandomOrder()
73
-                ->first();
74
-
75
-            return [
76
-                'offering_id' => $offering->id,
77
-                'unit_price' => $offering->price,
78
-            ];
79
-        })->afterCreating(function (DocumentLineItem $lineItem) {
80
-            $offering = $lineItem->offering;
81
-
82
-            if ($offering) {
83
-                $lineItem->salesTaxes()->syncWithoutDetaching($offering->salesTaxes->pluck('id')->toArray());
84
-                $lineItem->salesDiscounts()->syncWithoutDetaching($offering->salesDiscounts->pluck('id')->toArray());
85
-            }
86
-
87
-            $lineItem->refresh();
88
-
89
-            $taxTotal = $lineItem->calculateTaxTotal()->getAmount();
90
-            $discountTotal = $lineItem->calculateDiscountTotal()->getAmount();
91
-
92
-            $lineItem->updateQuietly([
93
-                'tax_total' => $taxTotal,
94
-                'discount_total' => $discountTotal,
95
-            ]);
96
-        });
76
+        return $this
77
+            ->for($estimate, 'documentable')
78
+            ->state(function (array $attributes) {
79
+                $offering = Offering::where('sellable', true)
80
+                    ->inRandomOrder()
81
+                    ->first();
82
+
83
+                return [
84
+                    'offering_id' => $offering->id,
85
+                    'unit_price' => $offering->price,
86
+                ];
87
+            })
88
+            ->afterCreating(function (DocumentLineItem $lineItem) {
89
+                $offering = $lineItem->offering;
90
+
91
+                if ($offering) {
92
+                    $lineItem->salesTaxes()->syncWithoutDetaching($offering->salesTaxes->pluck('id')->toArray());
93
+                    $lineItem->salesDiscounts()->syncWithoutDetaching($offering->salesDiscounts->pluck('id')->toArray());
94
+                }
95
+
96
+                $lineItem->refresh();
97
+
98
+                $taxTotal = $lineItem->calculateTaxTotal()->getAmount();
99
+                $discountTotal = $lineItem->calculateDiscountTotal()->getAmount();
100
+
101
+                $lineItem->updateQuietly([
102
+                    'tax_total' => $taxTotal,
103
+                    'discount_total' => $discountTotal,
104
+                ]);
105
+            });
97 106
     }
98 107
 
99
-    public function forBill(): static
108
+    public function forBill(Bill $bill): static
100 109
     {
101
-        return $this->state(function (array $attributes) {
102
-            $offering = Offering::where('purchasable', true)
103
-                ->inRandomOrder()
104
-                ->first();
105
-
106
-            return [
107
-                'offering_id' => $offering->id,
108
-                'unit_price' => $offering->price,
109
-            ];
110
-        })->afterCreating(function (DocumentLineItem $lineItem) {
111
-            $offering = $lineItem->offering;
112
-
113
-            if ($offering) {
114
-                $lineItem->purchaseTaxes()->syncWithoutDetaching($offering->purchaseTaxes->pluck('id')->toArray());
115
-                $lineItem->purchaseDiscounts()->syncWithoutDetaching($offering->purchaseDiscounts->pluck('id')->toArray());
116
-            }
117
-
118
-            $lineItem->refresh();
119
-
120
-            $taxTotal = $lineItem->calculateTaxTotal()->getAmount();
121
-            $discountTotal = $lineItem->calculateDiscountTotal()->getAmount();
122
-
123
-            $lineItem->updateQuietly([
124
-                'tax_total' => $taxTotal,
125
-                'discount_total' => $discountTotal,
126
-            ]);
127
-        });
110
+        return $this
111
+            ->for($bill, 'documentable')
112
+            ->state(function (array $attributes) {
113
+                $offering = Offering::where('purchasable', true)
114
+                    ->inRandomOrder()
115
+                    ->first();
116
+
117
+                return [
118
+                    'offering_id' => $offering->id,
119
+                    'unit_price' => $offering->price,
120
+                ];
121
+            })
122
+            ->afterCreating(function (DocumentLineItem $lineItem) {
123
+                $offering = $lineItem->offering;
124
+
125
+                if ($offering) {
126
+                    $lineItem->purchaseTaxes()->syncWithoutDetaching($offering->purchaseTaxes->pluck('id')->toArray());
127
+                    $lineItem->purchaseDiscounts()->syncWithoutDetaching($offering->purchaseDiscounts->pluck('id')->toArray());
128
+                }
129
+
130
+                $lineItem->refresh();
131
+
132
+                $taxTotal = $lineItem->calculateTaxTotal()->getAmount();
133
+                $discountTotal = $lineItem->calculateDiscountTotal()->getAmount();
134
+
135
+                $lineItem->updateQuietly([
136
+                    'tax_total' => $taxTotal,
137
+                    'discount_total' => $discountTotal,
138
+                ]);
139
+            });
128 140
     }
129 141
 }

+ 87
- 42
database/factories/Accounting/EstimateFactory.php Näytä tiedosto

@@ -46,21 +46,29 @@ class EstimateFactory extends Factory
46 46
         ];
47 47
     }
48 48
 
49
-    public function withLineItems(int $count = 3): self
49
+    public function withLineItems(int $count = 3): static
50 50
     {
51
-        return $this->has(DocumentLineItem::factory()->forEstimate()->count($count), 'lineItems');
51
+        return $this->afterCreating(function (Estimate $estimate) use ($count) {
52
+            DocumentLineItem::factory()
53
+                ->count($count)
54
+                ->forEstimate($estimate)
55
+                ->create();
56
+
57
+            $this->recalculateTotals($estimate);
58
+        });
52 59
     }
53 60
 
54 61
     public function approved(): static
55 62
     {
56 63
         return $this->afterCreating(function (Estimate $estimate) {
57
-            if (! $estimate->isDraft()) {
64
+            $this->ensureLineItems($estimate);
65
+
66
+            if (! $estimate->canBeApproved()) {
58 67
                 return;
59 68
             }
60 69
 
61
-            $this->recalculateTotals($estimate);
62
-
63
-            $approvedAt = Carbon::parse($estimate->date)->addHours($this->faker->numberBetween(1, 24));
70
+            $approvedAt = Carbon::parse($estimate->date)
71
+                ->addHours($this->faker->numberBetween(1, 24));
64 72
 
65 73
             $estimate->approveDraft($approvedAt);
66 74
         });
@@ -69,11 +77,9 @@ class EstimateFactory extends Factory
69 77
     public function accepted(): static
70 78
     {
71 79
         return $this->afterCreating(function (Estimate $estimate) {
72
-            if (! $estimate->isApproved()) {
73
-                $this->approved()->create();
74
-            }
80
+            $this->ensureSent($estimate);
75 81
 
76
-            $acceptedAt = Carbon::parse($estimate->approved_at)
82
+            $acceptedAt = Carbon::parse($estimate->last_sent_at)
77 83
                 ->addDays($this->faker->numberBetween(1, 7));
78 84
 
79 85
             $estimate->markAsAccepted($acceptedAt);
@@ -83,8 +89,8 @@ class EstimateFactory extends Factory
83 89
     public function converted(): static
84 90
     {
85 91
         return $this->afterCreating(function (Estimate $estimate) {
86
-            if (! $estimate->isAccepted()) {
87
-                $this->accepted()->create();
92
+            if (! $estimate->wasAccepted()) {
93
+                $this->accepted()->callAfterCreating(collect([$estimate]));
88 94
             }
89 95
 
90 96
             $convertedAt = Carbon::parse($estimate->accepted_at)
@@ -97,38 +103,55 @@ class EstimateFactory extends Factory
97 103
     public function declined(): static
98 104
     {
99 105
         return $this->afterCreating(function (Estimate $estimate) {
100
-            if (! $estimate->isApproved()) {
101
-                $this->approved()->create();
102
-            }
106
+            $this->ensureSent($estimate);
103 107
 
104
-            $declinedAt = Carbon::parse($estimate->approved_at)
108
+            $declinedAt = Carbon::parse($estimate->last_sent_at)
105 109
                 ->addDays($this->faker->numberBetween(1, 7));
106 110
 
107
-            $estimate->update([
108
-                'status' => EstimateStatus::Declined,
109
-                'declined_at' => $declinedAt,
110
-            ]);
111
+            $estimate->markAsDeclined($declinedAt);
111 112
         });
112 113
     }
113 114
 
114 115
     public function sent(): static
115 116
     {
116 117
         return $this->afterCreating(function (Estimate $estimate) {
117
-            if (! $estimate->isDraft()) {
118
-                return;
119
-            }
118
+            $this->ensureApproved($estimate);
120 119
 
121
-            $this->recalculateTotals($estimate);
122
-
123
-            $sentAt = Carbon::parse($estimate->date)->addHours($this->faker->numberBetween(1, 24));
120
+            $sentAt = Carbon::parse($estimate->approved_at)
121
+                ->addHours($this->faker->numberBetween(1, 24));
124 122
 
125 123
             $estimate->markAsSent($sentAt);
126 124
         });
127 125
     }
128 126
 
127
+    public function viewed(): static
128
+    {
129
+        return $this->afterCreating(function (Estimate $estimate) {
130
+            $this->ensureSent($estimate);
131
+
132
+            $viewedAt = Carbon::parse($estimate->last_sent_at)
133
+                ->addHours($this->faker->numberBetween(1, 24));
134
+
135
+            $estimate->markAsViewed($viewedAt);
136
+        });
137
+    }
138
+
139
+    public function expired(): static
140
+    {
141
+        return $this
142
+            ->state([
143
+                'expiration_date' => now()->subDays($this->faker->numberBetween(1, 30)),
144
+            ])
145
+            ->afterCreating(function (Estimate $estimate) {
146
+                $this->ensureApproved($estimate);
147
+            });
148
+    }
149
+
129 150
     public function configure(): static
130 151
     {
131 152
         return $this->afterCreating(function (Estimate $estimate) {
153
+            $this->ensureLineItems($estimate);
154
+
132 155
             $paddedId = str_pad((string) $estimate->id, 5, '0', STR_PAD_LEFT);
133 156
 
134 157
             $estimate->updateQuietly([
@@ -136,9 +159,7 @@ class EstimateFactory extends Factory
136 159
                 'reference_number' => "REF-{$paddedId}",
137 160
             ]);
138 161
 
139
-            $this->recalculateTotals($estimate);
140
-
141
-            if ($estimate->approved_at && $estimate->is_currently_expired) {
162
+            if ($estimate->wasApproved() && $estimate->is_currently_expired) {
142 163
                 $estimate->updateQuietly([
143 164
                     'status' => EstimateStatus::Expired,
144 165
                 ]);
@@ -146,21 +167,45 @@ class EstimateFactory extends Factory
146 167
         });
147 168
     }
148 169
 
170
+    protected function ensureLineItems(Estimate $estimate): void
171
+    {
172
+        if (! $estimate->hasLineItems()) {
173
+            $this->withLineItems()->callAfterCreating(collect([$estimate]));
174
+        }
175
+    }
176
+
177
+    protected function ensureApproved(Estimate $estimate): void
178
+    {
179
+        if (! $estimate->wasApproved()) {
180
+            $this->approved()->callAfterCreating(collect([$estimate]));
181
+        }
182
+    }
183
+
184
+    protected function ensureSent(Estimate $estimate): void
185
+    {
186
+        if (! $estimate->hasBeenSent()) {
187
+            $this->sent()->callAfterCreating(collect([$estimate]));
188
+        }
189
+    }
190
+
149 191
     protected function recalculateTotals(Estimate $estimate): void
150 192
     {
151
-        if ($estimate->lineItems()->exists()) {
152
-            $estimate->refresh();
153
-            $subtotal = $estimate->lineItems()->sum('subtotal') / 100;
154
-            $taxTotal = $estimate->lineItems()->sum('tax_total') / 100;
155
-            $discountTotal = $estimate->lineItems()->sum('discount_total') / 100;
156
-            $grandTotal = $subtotal + $taxTotal - $discountTotal;
157
-
158
-            $estimate->update([
159
-                'subtotal' => $subtotal,
160
-                'tax_total' => $taxTotal,
161
-                'discount_total' => $discountTotal,
162
-                'total' => $grandTotal,
163
-            ]);
193
+        $estimate->refresh();
194
+
195
+        if (! $estimate->hasLineItems()) {
196
+            return;
164 197
         }
198
+
199
+        $subtotal = $estimate->lineItems()->sum('subtotal') / 100;
200
+        $taxTotal = $estimate->lineItems()->sum('tax_total') / 100;
201
+        $discountTotal = $estimate->lineItems()->sum('discount_total') / 100;
202
+        $grandTotal = $subtotal + $taxTotal - $discountTotal;
203
+
204
+        $estimate->update([
205
+            'subtotal' => $subtotal,
206
+            'tax_total' => $taxTotal,
207
+            'discount_total' => $discountTotal,
208
+            'total' => $grandTotal,
209
+        ]);
165 210
     }
166 211
 }

+ 125
- 43
database/factories/Accounting/InvoiceFactory.php Näytä tiedosto

@@ -49,45 +49,103 @@ class InvoiceFactory extends Factory
49 49
         ];
50 50
     }
51 51
 
52
-    public function withLineItems(int $count = 3): self
52
+    public function withLineItems(int $count = 3): static
53 53
     {
54
-        return $this->has(DocumentLineItem::factory()->forInvoice()->count($count), 'lineItems');
54
+        return $this->afterCreating(function (Invoice $invoice) use ($count) {
55
+            DocumentLineItem::factory()
56
+                ->count($count)
57
+                ->forInvoice($invoice)
58
+                ->create();
59
+
60
+            $this->recalculateTotals($invoice);
61
+        });
55 62
     }
56 63
 
57 64
     public function approved(): static
58 65
     {
59 66
         return $this->afterCreating(function (Invoice $invoice) {
60
-            if (! $invoice->isDraft()) {
67
+            $this->ensureLineItems($invoice);
68
+
69
+            if (! $invoice->canBeApproved()) {
61 70
                 return;
62 71
             }
63 72
 
64
-            $this->recalculateTotals($invoice);
65
-
66
-            $approvedAt = Carbon::parse($invoice->date)->addHours($this->faker->numberBetween(1, 24));
73
+            $approvedAt = Carbon::parse($invoice->date)
74
+                ->addHours($this->faker->numberBetween(1, 24));
67 75
 
68 76
             $invoice->approveDraft($approvedAt);
69 77
         });
70 78
     }
71 79
 
72
-    public function withPayments(?int $min = 1, ?int $max = null, InvoiceStatus $invoiceStatus = InvoiceStatus::Paid): static
80
+    public function sent(): static
73 81
     {
74
-        return $this->afterCreating(function (Invoice $invoice) use ($invoiceStatus, $max, $min) {
75
-            if ($invoice->isDraft()) {
76
-                $this->recalculateTotals($invoice);
82
+        return $this->afterCreating(function (Invoice $invoice) {
83
+            $this->ensureApproved($invoice);
77 84
 
78
-                $approvedAt = Carbon::parse($invoice->date)->addHours($this->faker->numberBetween(1, 24));
79
-                $invoice->approveDraft($approvedAt);
80
-            }
85
+            $sentAt = Carbon::parse($invoice->approved_at)
86
+                ->addHours($this->faker->numberBetween(1, 24));
87
+
88
+            $invoice->markAsSent($sentAt);
89
+        });
90
+    }
91
+
92
+    public function partial(int $maxPayments = 4): static
93
+    {
94
+        return $this->afterCreating(function (Invoice $invoice) use ($maxPayments) {
95
+            $this->ensureSent($invoice);
96
+
97
+            $this->withPayments(max: $maxPayments, invoiceStatus: InvoiceStatus::Partial)
98
+                ->callAfterCreating(collect([$invoice]));
99
+        });
100
+    }
101
+
102
+    public function paid(int $maxPayments = 4): static
103
+    {
104
+        return $this->afterCreating(function (Invoice $invoice) use ($maxPayments) {
105
+            $this->ensureSent($invoice);
106
+
107
+            $this->withPayments(max: $maxPayments)
108
+                ->callAfterCreating(collect([$invoice]));
109
+        });
110
+    }
111
+
112
+    public function overpaid(int $maxPayments = 4): static
113
+    {
114
+        return $this->afterCreating(function (Invoice $invoice) use ($maxPayments) {
115
+            $this->ensureSent($invoice);
116
+
117
+            $this->withPayments(max: $maxPayments, invoiceStatus: InvoiceStatus::Overpaid)
118
+                ->callAfterCreating(collect([$invoice]));
119
+        });
120
+    }
121
+
122
+    public function overdue(): static
123
+    {
124
+        return $this
125
+            ->state([
126
+                'due_date' => now()->subDays($this->faker->numberBetween(1, 30)),
127
+            ])
128
+            ->afterCreating(function (Invoice $invoice) {
129
+                $this->ensureApproved($invoice);
130
+            });
131
+    }
132
+
133
+    public function withPayments(?int $min = null, ?int $max = null, InvoiceStatus $invoiceStatus = InvoiceStatus::Paid): static
134
+    {
135
+        $min ??= 1;
136
+
137
+        return $this->afterCreating(function (Invoice $invoice) use ($invoiceStatus, $max, $min) {
138
+            $this->ensureSent($invoice);
81 139
 
82 140
             $invoice->refresh();
83 141
 
84
-            $totalAmountDue = $invoice->getRawOriginal('amount_due');
142
+            $amountDue = $invoice->getRawOriginal('amount_due');
85 143
 
86
-            if ($invoiceStatus === InvoiceStatus::Overpaid) {
87
-                $totalAmountDue += random_int(1000, 10000);
88
-            } elseif ($invoiceStatus === InvoiceStatus::Partial) {
89
-                $totalAmountDue = (int) floor($totalAmountDue * 0.5);
90
-            }
144
+            $totalAmountDue = match ($invoiceStatus) {
145
+                InvoiceStatus::Overpaid => $amountDue + random_int(1000, 10000),
146
+                InvoiceStatus::Partial => (int) floor($amountDue * 0.5),
147
+                default => $amountDue,
148
+            };
91 149
 
92 150
             if ($totalAmountDue <= 0 || empty($totalAmountDue)) {
93 151
                 return;
@@ -122,21 +180,23 @@ class InvoiceFactory extends Factory
122 180
                 $remainingAmount -= $amount;
123 181
             }
124 182
 
125
-            // If it's a paid invoice, use the latest payment date as paid_at
126
-            if ($invoiceStatus === InvoiceStatus::Paid) {
127
-                $latestPaymentDate = max($paymentDates);
128
-                $invoice->updateQuietly([
129
-                    'status' => $invoiceStatus,
130
-                    'paid_at' => $latestPaymentDate,
131
-                ]);
183
+            if ($invoiceStatus !== InvoiceStatus::Paid) {
184
+                return;
132 185
             }
186
+
187
+            $latestPaymentDate = max($paymentDates);
188
+            $invoice->updateQuietly([
189
+                'status' => $invoiceStatus,
190
+                'paid_at' => $latestPaymentDate,
191
+            ]);
133 192
         });
134 193
     }
135 194
 
136 195
     public function configure(): static
137 196
     {
138 197
         return $this->afterCreating(function (Invoice $invoice) {
139
-            // Use the invoice's ID to generate invoice and order numbers
198
+            $this->ensureLineItems($invoice);
199
+
140 200
             $paddedId = str_pad((string) $invoice->id, 5, '0', STR_PAD_LEFT);
141 201
 
142 202
             $invoice->updateQuietly([
@@ -144,9 +204,7 @@ class InvoiceFactory extends Factory
144 204
                 'order_number' => "ORD-{$paddedId}",
145 205
             ]);
146 206
 
147
-            $this->recalculateTotals($invoice);
148
-
149
-            if ($invoice->approved_at && $invoice->is_currently_overdue) {
207
+            if ($invoice->wasApproved() && $invoice->is_currently_overdue) {
150 208
                 $invoice->updateQuietly([
151 209
                     'status' => InvoiceStatus::Overdue,
152 210
                 ]);
@@ -154,21 +212,45 @@ class InvoiceFactory extends Factory
154 212
         });
155 213
     }
156 214
 
215
+    protected function ensureLineItems(Invoice $invoice): void
216
+    {
217
+        if (! $invoice->hasLineItems()) {
218
+            $this->withLineItems()->callAfterCreating(collect([$invoice]));
219
+        }
220
+    }
221
+
222
+    protected function ensureApproved(Invoice $invoice): void
223
+    {
224
+        if (! $invoice->wasApproved()) {
225
+            $this->approved()->callAfterCreating(collect([$invoice]));
226
+        }
227
+    }
228
+
229
+    protected function ensureSent(Invoice $invoice): void
230
+    {
231
+        if (! $invoice->hasBeenSent()) {
232
+            $this->sent()->callAfterCreating(collect([$invoice]));
233
+        }
234
+    }
235
+
157 236
     protected function recalculateTotals(Invoice $invoice): void
158 237
     {
159
-        if ($invoice->lineItems()->exists()) {
160
-            $invoice->refresh();
161
-            $subtotal = $invoice->lineItems()->sum('subtotal') / 100;
162
-            $taxTotal = $invoice->lineItems()->sum('tax_total') / 100;
163
-            $discountTotal = $invoice->lineItems()->sum('discount_total') / 100;
164
-            $grandTotal = $subtotal + $taxTotal - $discountTotal;
165
-
166
-            $invoice->update([
167
-                'subtotal' => $subtotal,
168
-                'tax_total' => $taxTotal,
169
-                'discount_total' => $discountTotal,
170
-                'total' => $grandTotal,
171
-            ]);
238
+        $invoice->refresh();
239
+
240
+        if (! $invoice->hasLineItems()) {
241
+            return;
172 242
         }
243
+
244
+        $subtotal = $invoice->lineItems()->sum('subtotal') / 100;
245
+        $taxTotal = $invoice->lineItems()->sum('tax_total') / 100;
246
+        $discountTotal = $invoice->lineItems()->sum('discount_total') / 100;
247
+        $grandTotal = $subtotal + $taxTotal - $discountTotal;
248
+
249
+        $invoice->update([
250
+            'subtotal' => $subtotal,
251
+            'tax_total' => $taxTotal,
252
+            'discount_total' => $discountTotal,
253
+            'total' => $grandTotal,
254
+        ]);
173 255
     }
174 256
 }

+ 30
- 45
database/factories/CompanyFactory.php Näytä tiedosto

@@ -2,8 +2,6 @@
2 2
 
3 3
 namespace Database\Factories;
4 4
 
5
-use App\Enums\Accounting\BillStatus;
6
-use App\Enums\Accounting\InvoiceStatus;
7 5
 use App\Models\Accounting\Bill;
8 6
 use App\Models\Accounting\Estimate;
9 7
 use App\Models\Accounting\Invoice;
@@ -107,12 +105,12 @@ class CompanyFactory extends Factory
107 105
             $draftCount = (int) floor($count * 0.2);
108 106
             $approvedCount = (int) floor($count * 0.2);
109 107
             $paidCount = (int) floor($count * 0.3);
110
-            $partialCount = (int) floor($count * 0.2);
111
-            $overpaidCount = $count - ($draftCount + $approvedCount + $paidCount + $partialCount);
108
+            $partialCount = (int) floor($count * 0.1);
109
+            $overpaidCount = (int) floor($count * 0.1);
110
+            $overdueCount = $count - ($draftCount + $approvedCount + $paidCount + $partialCount + $overpaidCount);
112 111
 
113 112
             Invoice::factory()
114 113
                 ->count($draftCount)
115
-                ->withLineItems()
116 114
                 ->create([
117 115
                     'company_id' => $company->id,
118 116
                     'created_by' => $company->user_id,
@@ -121,7 +119,6 @@ class CompanyFactory extends Factory
121 119
 
122 120
             Invoice::factory()
123 121
                 ->count($approvedCount)
124
-                ->withLineItems()
125 122
                 ->approved()
126 123
                 ->create([
127 124
                     'company_id' => $company->id,
@@ -131,9 +128,7 @@ class CompanyFactory extends Factory
131 128
 
132 129
             Invoice::factory()
133 130
                 ->count($paidCount)
134
-                ->withLineItems()
135
-                ->approved()
136
-                ->withPayments(max: 4)
131
+                ->paid()
137 132
                 ->create([
138 133
                     'company_id' => $company->id,
139 134
                     'created_by' => $company->user_id,
@@ -142,9 +137,7 @@ class CompanyFactory extends Factory
142 137
 
143 138
             Invoice::factory()
144 139
                 ->count($partialCount)
145
-                ->withLineItems()
146
-                ->approved()
147
-                ->withPayments(max: 4, invoiceStatus: InvoiceStatus::Partial)
140
+                ->partial()
148 141
                 ->create([
149 142
                     'company_id' => $company->id,
150 143
                     'created_by' => $company->user_id,
@@ -153,9 +146,16 @@ class CompanyFactory extends Factory
153 146
 
154 147
             Invoice::factory()
155 148
                 ->count($overpaidCount)
156
-                ->withLineItems()
157
-                ->approved()
158
-                ->withPayments(max: 4, invoiceStatus: InvoiceStatus::Overpaid)
149
+                ->overpaid()
150
+                ->create([
151
+                    'company_id' => $company->id,
152
+                    'created_by' => $company->user_id,
153
+                    'updated_by' => $company->user_id,
154
+                ]);
155
+
156
+            Invoice::factory()
157
+                ->count($overdueCount)
158
+                ->overdue()
159 159
                 ->create([
160 160
                     'company_id' => $company->id,
161 161
                     'created_by' => $company->user_id,
@@ -174,20 +174,16 @@ class CompanyFactory extends Factory
174 174
             $convertedCount = (int) floor($count * 0.1); // 10% converted to invoices
175 175
             $expiredCount = $count - ($draftCount + $approvedCount + $acceptedCount + $declinedCount + $convertedCount); // remaining 10%
176 176
 
177
-            // Create draft estimates
178 177
             Estimate::factory()
179 178
                 ->count($draftCount)
180
-                ->withLineItems()
181 179
                 ->create([
182 180
                     'company_id' => $company->id,
183 181
                     'created_by' => $company->user_id,
184 182
                     'updated_by' => $company->user_id,
185 183
                 ]);
186 184
 
187
-            // Create pending (approved) estimates
188 185
             Estimate::factory()
189 186
                 ->count($approvedCount)
190
-                ->withLineItems()
191 187
                 ->approved()
192 188
                 ->create([
193 189
                     'company_id' => $company->id,
@@ -195,10 +191,8 @@ class CompanyFactory extends Factory
195 191
                     'updated_by' => $company->user_id,
196 192
                 ]);
197 193
 
198
-            // Create accepted estimates
199 194
             Estimate::factory()
200 195
                 ->count($acceptedCount)
201
-                ->withLineItems()
202 196
                 ->accepted()
203 197
                 ->create([
204 198
                     'company_id' => $company->id,
@@ -206,10 +200,8 @@ class CompanyFactory extends Factory
206 200
                     'updated_by' => $company->user_id,
207 201
                 ]);
208 202
 
209
-            // Create declined estimates
210 203
             Estimate::factory()
211 204
                 ->count($declinedCount)
212
-                ->withLineItems()
213 205
                 ->declined()
214 206
                 ->create([
215 207
                     'company_id' => $company->id,
@@ -217,11 +209,8 @@ class CompanyFactory extends Factory
217 209
                     'updated_by' => $company->user_id,
218 210
                 ]);
219 211
 
220
-            // Create converted estimates
221 212
             Estimate::factory()
222 213
                 ->count($convertedCount)
223
-                ->withLineItems()
224
-                ->accepted()
225 214
                 ->converted()
226 215
                 ->create([
227 216
                     'company_id' => $company->id,
@@ -229,14 +218,9 @@ class CompanyFactory extends Factory
229 218
                     'updated_by' => $company->user_id,
230 219
                 ]);
231 220
 
232
-            // Create expired estimates (approved but past expiration date)
233 221
             Estimate::factory()
234 222
                 ->count($expiredCount)
235
-                ->withLineItems()
236
-                ->approved()
237
-                ->state([
238
-                    'expiration_date' => now()->subDays(rand(1, 30)),
239
-                ])
223
+                ->expired()
240 224
                 ->create([
241 225
                     'company_id' => $company->id,
242 226
                     'created_by' => $company->user_id,
@@ -249,38 +233,39 @@ class CompanyFactory extends Factory
249 233
     {
250 234
         return $this->afterCreating(function (Company $company) use ($count) {
251 235
             $unpaidCount = (int) floor($count * 0.4);
252
-            $paidCount = (int) floor($count * 0.4);
253
-            $partialCount = $count - ($unpaidCount + $paidCount);
236
+            $paidCount = (int) floor($count * 0.3);
237
+            $partialCount = (int) floor($count * 0.2);
238
+            $overdueCount = $count - ($unpaidCount + $paidCount + $partialCount);
254 239
 
255
-            // Create unpaid bills
256 240
             Bill::factory()
257 241
                 ->count($unpaidCount)
258
-                ->withLineItems()
259
-                ->initialized()
260 242
                 ->create([
261 243
                     'company_id' => $company->id,
262 244
                     'created_by' => $company->user_id,
263 245
                     'updated_by' => $company->user_id,
264 246
                 ]);
265 247
 
266
-            // Create paid bills
267 248
             Bill::factory()
268 249
                 ->count($paidCount)
269
-                ->withLineItems()
270
-                ->initialized()
271
-                ->withPayments(max: 4)
250
+                ->paid()
272 251
                 ->create([
273 252
                     'company_id' => $company->id,
274 253
                     'created_by' => $company->user_id,
275 254
                     'updated_by' => $company->user_id,
276 255
                 ]);
277 256
 
278
-            // Create partially paid bills
279 257
             Bill::factory()
280 258
                 ->count($partialCount)
281
-                ->withLineItems()
282
-                ->initialized()
283
-                ->withPayments(max: 4, billStatus: BillStatus::Partial)
259
+                ->partial()
260
+                ->create([
261
+                    'company_id' => $company->id,
262
+                    'created_by' => $company->user_id,
263
+                    'updated_by' => $company->user_id,
264
+                ]);
265
+
266
+            Bill::factory()
267
+                ->count($overdueCount)
268
+                ->overdue()
284 269
                 ->create([
285 270
                     'company_id' => $company->id,
286 271
                     'created_by' => $company->user_id,

+ 1
- 0
database/migrations/2024_11_27_223015_create_invoices_table.php Näytä tiedosto

@@ -26,6 +26,7 @@ return new class extends Migration
26 26
             $table->timestamp('approved_at')->nullable();
27 27
             $table->timestamp('paid_at')->nullable();
28 28
             $table->timestamp('last_sent_at')->nullable();
29
+            $table->timestamp('last_viewed_at')->nullable();
29 30
             $table->string('status')->default('draft');
30 31
             $table->string('currency_code')->nullable();
31 32
             $table->string('discount_method')->default('per_line_item');

Loading…
Peruuta
Tallenna