|
@@ -13,6 +13,7 @@ use App\Enums\Accounting\DocumentType;
|
13
|
13
|
use App\Enums\Accounting\EndType;
|
14
|
14
|
use App\Enums\Accounting\Frequency;
|
15
|
15
|
use App\Enums\Accounting\IntervalType;
|
|
16
|
+use App\Enums\Accounting\InvoiceStatus;
|
16
|
17
|
use App\Enums\Accounting\Month;
|
17
|
18
|
use App\Enums\Accounting\RecurringInvoiceStatus;
|
18
|
19
|
use App\Enums\Setting\PaymentTerms;
|
|
@@ -28,6 +29,7 @@ use Filament\Forms\Form;
|
28
|
29
|
use Guava\FilamentClusters\Forms\Cluster;
|
29
|
30
|
use Illuminate\Database\Eloquent\Attributes\CollectedBy;
|
30
|
31
|
use Illuminate\Database\Eloquent\Attributes\ObservedBy;
|
|
32
|
+use Illuminate\Database\Eloquent\Model;
|
31
|
33
|
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
32
|
34
|
use Illuminate\Database\Eloquent\Relations\HasMany;
|
33
|
35
|
use Illuminate\Support\Carbon;
|
|
@@ -91,6 +93,7 @@ class RecurringInvoice extends Document
|
91
|
93
|
'payment_terms' => PaymentTerms::class,
|
92
|
94
|
'frequency' => Frequency::class,
|
93
|
95
|
'interval_type' => IntervalType::class,
|
|
96
|
+ 'interval_value' => 'integer',
|
94
|
97
|
'month' => Month::class,
|
95
|
98
|
'day_of_month' => DayOfMonth::class,
|
96
|
99
|
'day_of_week' => DayOfWeek::class,
|
|
@@ -223,9 +226,6 @@ class RecurringInvoice extends Document
|
223
|
226
|
return "Repeat every {$interval}{$dayDescription}";
|
224
|
227
|
}
|
225
|
228
|
|
226
|
|
- /**
|
227
|
|
- * Get a human-readable description of when the schedule ends.
|
228
|
|
- */
|
229
|
229
|
public function getEndDescription(): string
|
230
|
230
|
{
|
231
|
231
|
if (! $this->end_type) {
|
|
@@ -243,9 +243,6 @@ class RecurringInvoice extends Document
|
243
|
243
|
};
|
244
|
244
|
}
|
245
|
245
|
|
246
|
|
- /**
|
247
|
|
- * Get the schedule timeline description.
|
248
|
|
- */
|
249
|
246
|
public function getTimelineDescription(): string
|
250
|
247
|
{
|
251
|
248
|
$parts = [];
|
|
@@ -261,12 +258,14 @@ class RecurringInvoice extends Document
|
261
|
258
|
return implode(', ', $parts);
|
262
|
259
|
}
|
263
|
260
|
|
264
|
|
- /**
|
265
|
|
- * Get next occurrence date based on the schedule.
|
266
|
|
- */
|
267
|
|
- public function calculateNextDate(): ?Carbon
|
|
261
|
+ public function calculateNextDate(?Carbon $lastDate = null): ?Carbon
|
268
|
262
|
{
|
269
|
|
- $lastDate = $this->last_date ?? $this->start_date;
|
|
263
|
+ $lastDate ??= $this->last_date;
|
|
264
|
+
|
|
265
|
+ if (! $lastDate && $this->start_date) {
|
|
266
|
+ return $this->start_date;
|
|
267
|
+ }
|
|
268
|
+
|
270
|
269
|
if (! $lastDate) {
|
271
|
270
|
return null;
|
272
|
271
|
}
|
|
@@ -274,59 +273,109 @@ class RecurringInvoice extends Document
|
274
|
273
|
$nextDate = match (true) {
|
275
|
274
|
$this->frequency->isDaily() => $lastDate->addDay(),
|
276
|
275
|
|
277
|
|
- $this->frequency->isWeekly() => $lastDate->addWeek(),
|
|
276
|
+ $this->frequency->isWeekly() => $this->calculateNextWeeklyDate($lastDate),
|
278
|
277
|
|
279
|
|
- $this->frequency->isMonthly() => $lastDate->addMonth(),
|
|
278
|
+ $this->frequency->isMonthly() => $this->calculateNextMonthlyDate($lastDate),
|
280
|
279
|
|
281
|
|
- $this->frequency->isYearly() => $lastDate->addYear(),
|
|
280
|
+ $this->frequency->isYearly() => $this->calculateNextYearlyDate($lastDate),
|
282
|
281
|
|
283
|
282
|
$this->frequency->isCustom() => $this->calculateCustomNextDate($lastDate),
|
284
|
283
|
|
285
|
284
|
default => null
|
286
|
285
|
};
|
287
|
286
|
|
288
|
|
- // Check if we've reached the end
|
289
|
|
- if ($this->hasReachedEnd($nextDate)) {
|
|
287
|
+ if (! $nextDate || $this->hasReachedEnd($nextDate)) {
|
290
|
288
|
return null;
|
291
|
289
|
}
|
292
|
290
|
|
293
|
291
|
return $nextDate;
|
294
|
292
|
}
|
295
|
293
|
|
296
|
|
- public function calculateNextDueDate(): ?Carbon
|
|
294
|
+ public function calculateNextWeeklyDate(Carbon $lastDate): ?Carbon
|
297
|
295
|
{
|
298
|
|
- $nextDate = $this->calculateNextDate();
|
299
|
|
- if (! $nextDate) {
|
300
|
|
- return null;
|
301
|
|
- }
|
|
296
|
+ return $lastDate->copy()->next($this->day_of_week->value);
|
|
297
|
+ }
|
302
|
298
|
|
303
|
|
- $terms = $this->payment_terms;
|
304
|
|
- if (! $terms) {
|
305
|
|
- return $nextDate;
|
306
|
|
- }
|
|
299
|
+ public function calculateNextMonthlyDate(Carbon $lastDate): ?Carbon
|
|
300
|
+ {
|
|
301
|
+ return match (true) {
|
|
302
|
+ $lastDate->equalTo($this->start_date) => $lastDate->copy()->day(
|
|
303
|
+ min($this->day_of_month->value, $lastDate->daysInMonth)
|
|
304
|
+ ),
|
307
|
305
|
|
308
|
|
- return $nextDate->addDays($terms->getDays());
|
|
306
|
+ default => $lastDate->copy()->addMonth()->day(
|
|
307
|
+ min($this->day_of_month->value, $lastDate->copy()->addMonth()->daysInMonth)
|
|
308
|
+ ),
|
|
309
|
+ };
|
309
|
310
|
}
|
310
|
311
|
|
311
|
|
- /**
|
312
|
|
- * Calculate next date for custom intervals
|
313
|
|
- */
|
314
|
|
- protected function calculateCustomNextDate(Carbon $lastDate): ?\Carbon\Carbon
|
|
312
|
+ public function calculateNextYearlyDate(Carbon $lastDate): ?Carbon
|
315
|
313
|
{
|
316
|
|
- $value = $this->interval_value ?? 1;
|
|
314
|
+ return match (true) {
|
|
315
|
+ $lastDate->equalTo($this->start_date) => $lastDate->copy()
|
|
316
|
+ ->month($this->month->value)
|
|
317
|
+ ->day(min($this->day_of_month->value, $lastDate->daysInMonth)),
|
|
318
|
+
|
|
319
|
+ default => $lastDate->copy()
|
|
320
|
+ ->addYear()
|
|
321
|
+ ->month($this->month->value)
|
|
322
|
+ ->day(min($this->day_of_month->value, $lastDate->copy()->addYear()->month($this->month->value)->daysInMonth))
|
|
323
|
+ };
|
|
324
|
+ }
|
|
325
|
+
|
|
326
|
+ protected function calculateCustomNextDate(Carbon $lastDate): ?Carbon
|
|
327
|
+ {
|
|
328
|
+ $interval = $this->interval_value ?? 1;
|
317
|
329
|
|
318
|
330
|
return match ($this->interval_type) {
|
319
|
|
- IntervalType::Day => $lastDate->addDays($value),
|
320
|
|
- IntervalType::Week => $lastDate->addWeeks($value),
|
321
|
|
- IntervalType::Month => $lastDate->addMonths($value),
|
322
|
|
- IntervalType::Year => $lastDate->addYears($value),
|
|
331
|
+ IntervalType::Day => $lastDate->copy()->addDays($interval),
|
|
332
|
+
|
|
333
|
+ IntervalType::Week => match (true) {
|
|
334
|
+ $lastDate->equalTo($this->start_date) => $lastDate->copy()->next($this->day_of_week->value),
|
|
335
|
+
|
|
336
|
+ $lastDate->dayOfWeek === $this->day_of_week->value => $lastDate->copy()->addWeeks($interval),
|
|
337
|
+
|
|
338
|
+ default => $lastDate->copy()->next($this->day_of_week->value),
|
|
339
|
+ },
|
|
340
|
+
|
|
341
|
+ IntervalType::Month => match (true) {
|
|
342
|
+ $lastDate->equalTo($this->start_date) => $lastDate->copy()->day(
|
|
343
|
+ min($this->day_of_month->value, $lastDate->daysInMonth)
|
|
344
|
+ ),
|
|
345
|
+
|
|
346
|
+ default => $lastDate->copy()->addMonths($interval)->day(
|
|
347
|
+ min($this->day_of_month->value, $lastDate->copy()->addMonths($interval)->daysInMonth)
|
|
348
|
+ ),
|
|
349
|
+ },
|
|
350
|
+
|
|
351
|
+ IntervalType::Year => match (true) {
|
|
352
|
+ $lastDate->equalTo($this->start_date) => $lastDate->copy()
|
|
353
|
+ ->month($this->month->value)
|
|
354
|
+ ->day(min($this->day_of_month->value, $lastDate->daysInMonth)),
|
|
355
|
+
|
|
356
|
+ default => $lastDate->copy()
|
|
357
|
+ ->addYears($interval)
|
|
358
|
+ ->month($this->month->value)
|
|
359
|
+ ->day(min($this->day_of_month->value, $lastDate->copy()->addYears($interval)->month($this->month->value)->daysInMonth))
|
|
360
|
+ },
|
|
361
|
+
|
323
|
362
|
default => null
|
324
|
363
|
};
|
325
|
364
|
}
|
326
|
365
|
|
327
|
|
- /**
|
328
|
|
- * Check if the schedule has reached its end
|
329
|
|
- */
|
|
366
|
+ public function calculateNextDueDate(): ?Carbon
|
|
367
|
+ {
|
|
368
|
+ if (! $nextDate = $this->calculateNextDate()) {
|
|
369
|
+ return null;
|
|
370
|
+ }
|
|
371
|
+
|
|
372
|
+ if (! $terms = $this->payment_terms) {
|
|
373
|
+ return $nextDate;
|
|
374
|
+ }
|
|
375
|
+
|
|
376
|
+ return $nextDate->copy()->addDays($terms->getDays());
|
|
377
|
+ }
|
|
378
|
+
|
330
|
379
|
public function hasReachedEnd(?Carbon $nextDate = null): bool
|
331
|
380
|
{
|
332
|
381
|
if (! $this->end_type) {
|
|
@@ -464,18 +513,74 @@ class RecurringInvoice extends Document
|
464
|
513
|
->visible(
|
465
|
514
|
fn (Forms\Get $get) => Frequency::parse($get('frequency'))->isYearly() ||
|
466
|
515
|
IntervalType::parse($get('interval_type'))?->isYear()
|
467
|
|
- ),
|
|
516
|
+ )
|
|
517
|
+ ->live()
|
|
518
|
+ ->afterStateUpdated(function (Forms\Set $set, Forms\Get $get, $state) {
|
|
519
|
+ $dayOfMonth = DayOfMonth::parse($get('day_of_month'));
|
|
520
|
+ $frequency = Frequency::parse($get('frequency'));
|
|
521
|
+ $intervalType = IntervalType::parse($get('interval_type'));
|
|
522
|
+ $month = Month::parse($state);
|
|
523
|
+
|
|
524
|
+ if (($frequency->isYearly() || $intervalType?->isYear()) && $month && $dayOfMonth) {
|
|
525
|
+ $date = $dayOfMonth->resolveDate(today()->month($month->value))->toImmutable();
|
|
526
|
+
|
|
527
|
+ $adjustedStartDate = $date->lt(today())
|
|
528
|
+ ? $dayOfMonth->resolveDate($date->addYear()->month($month->value))
|
|
529
|
+ : $dayOfMonth->resolveDate($date->month($month->value));
|
|
530
|
+
|
|
531
|
+ $adjustedDay = min($dayOfMonth->value, $adjustedStartDate->daysInMonth);
|
|
532
|
+
|
|
533
|
+ $set('day_of_month', $adjustedDay);
|
|
534
|
+
|
|
535
|
+ $set('start_date', $adjustedStartDate);
|
|
536
|
+ }
|
|
537
|
+ }),
|
468
|
538
|
|
469
|
539
|
Forms\Components\Select::make('day_of_month')
|
470
|
540
|
->label('Day of Month')
|
471
|
|
- ->options(DayOfMonth::class)
|
|
541
|
+ ->options(function (Forms\Get $get) {
|
|
542
|
+ $month = Month::parse($get('month')) ?? Month::January;
|
|
543
|
+
|
|
544
|
+ $daysInMonth = Carbon::createFromDate(null, $month->value)->daysInMonth;
|
|
545
|
+
|
|
546
|
+ return collect(DayOfMonth::cases())
|
|
547
|
+ ->filter(static fn (DayOfMonth $dayOfMonth) => $dayOfMonth->value <= $daysInMonth || $dayOfMonth->isLast())
|
|
548
|
+ ->mapWithKeys(fn (DayOfMonth $dayOfMonth) => [$dayOfMonth->value => $dayOfMonth->getLabel()]);
|
|
549
|
+ })
|
472
|
550
|
->softRequired()
|
473
|
551
|
->visible(
|
474
|
552
|
fn (Forms\Get $get) => Frequency::parse($get('frequency'))?->isMonthly() ||
|
475
|
553
|
Frequency::parse($get('frequency'))?->isYearly() ||
|
476
|
554
|
IntervalType::parse($get('interval_type'))?->isMonth() ||
|
477
|
555
|
IntervalType::parse($get('interval_type'))?->isYear()
|
478
|
|
- ),
|
|
556
|
+ )
|
|
557
|
+ ->live()
|
|
558
|
+ ->afterStateUpdated(function (Forms\Set $set, Forms\Get $get, $state) {
|
|
559
|
+ $dayOfMonth = DayOfMonth::parse($state);
|
|
560
|
+ $frequency = Frequency::parse($get('frequency'));
|
|
561
|
+ $intervalType = IntervalType::parse($get('interval_type'));
|
|
562
|
+ $month = Month::parse($get('month'));
|
|
563
|
+
|
|
564
|
+ if (($frequency->isMonthly() || $intervalType?->isMonth()) && $dayOfMonth) {
|
|
565
|
+ $date = $dayOfMonth->resolveDate(today())->toImmutable();
|
|
566
|
+
|
|
567
|
+ $adjustedStartDate = $date->lt(today())
|
|
568
|
+ ? $dayOfMonth->resolveDate($date->addMonth())
|
|
569
|
+ : $dayOfMonth->resolveDate($date);
|
|
570
|
+
|
|
571
|
+ $set('start_date', $adjustedStartDate);
|
|
572
|
+ }
|
|
573
|
+
|
|
574
|
+ if (($frequency->isYearly() || $intervalType?->isYear()) && $month && $dayOfMonth) {
|
|
575
|
+ $date = $dayOfMonth->resolveDate(today()->month($month->value))->toImmutable();
|
|
576
|
+
|
|
577
|
+ $adjustedStartDate = $date->lt(today())
|
|
578
|
+ ? $dayOfMonth->resolveDate($date->addYear()->month($month->value))
|
|
579
|
+ : $dayOfMonth->resolveDate($date->month($month->value));
|
|
580
|
+
|
|
581
|
+ $set('start_date', $adjustedStartDate);
|
|
582
|
+ }
|
|
583
|
+ }),
|
479
|
584
|
|
480
|
585
|
Forms\Components\Select::make('day_of_week')
|
481
|
586
|
->label('Day of Week')
|
|
@@ -484,7 +589,17 @@ class RecurringInvoice extends Document
|
484
|
589
|
->visible(
|
485
|
590
|
fn (Forms\Get $get) => Frequency::parse($get('frequency'))?->isWeekly() ||
|
486
|
591
|
IntervalType::parse($get('interval_type'))?->isWeek()
|
487
|
|
- ),
|
|
592
|
+ )
|
|
593
|
+ ->live()
|
|
594
|
+ ->afterStateUpdated(function (Forms\Set $set, $state) {
|
|
595
|
+ $dayOfWeek = DayOfWeek::parse($state);
|
|
596
|
+
|
|
597
|
+ $adjustedStartDate = today()->is($dayOfWeek->name)
|
|
598
|
+ ? today()
|
|
599
|
+ : today()->next($dayOfWeek->name);
|
|
600
|
+
|
|
601
|
+ $set('start_date', $adjustedStartDate);
|
|
602
|
+ }),
|
488
|
603
|
])->columns(2),
|
489
|
604
|
|
490
|
605
|
CustomSection::make('Dates & Time')
|
|
@@ -492,7 +607,17 @@ class RecurringInvoice extends Document
|
492
|
607
|
->schema([
|
493
|
608
|
Forms\Components\DatePicker::make('start_date')
|
494
|
609
|
->label('First Invoice Date')
|
495
|
|
- ->softRequired(),
|
|
610
|
+ ->softRequired()
|
|
611
|
+ ->live()
|
|
612
|
+ ->minDate(today())
|
|
613
|
+ ->closeOnDateSelection()
|
|
614
|
+ ->afterStateUpdated(function (Forms\Set $set, $state) {
|
|
615
|
+ $startDate = Carbon::parse($state);
|
|
616
|
+
|
|
617
|
+ $dayOfWeek = DayOfWeek::parse($startDate->dayOfWeek);
|
|
618
|
+
|
|
619
|
+ $set('day_of_week', $dayOfWeek);
|
|
620
|
+ }),
|
496
|
621
|
|
497
|
622
|
Forms\Components\Group::make(function (Forms\Get $get) {
|
498
|
623
|
$components = [];
|
|
@@ -587,4 +712,105 @@ class RecurringInvoice extends Document
|
587
|
712
|
'status' => RecurringInvoiceStatus::Active,
|
588
|
713
|
]);
|
589
|
714
|
}
|
|
715
|
+
|
|
716
|
+ public function generateInvoice(): ?Invoice
|
|
717
|
+ {
|
|
718
|
+ if (! $this->shouldGenerateInvoice()) {
|
|
719
|
+ return null;
|
|
720
|
+ }
|
|
721
|
+
|
|
722
|
+ $nextDate = $this->next_date ?? $this->calculateNextDate();
|
|
723
|
+
|
|
724
|
+ if (! $nextDate) {
|
|
725
|
+ return null;
|
|
726
|
+ }
|
|
727
|
+
|
|
728
|
+ $dueDate = $this->calculateNextDueDate();
|
|
729
|
+
|
|
730
|
+ $invoice = $this->invoices()->create([
|
|
731
|
+ 'company_id' => $this->company_id,
|
|
732
|
+ 'client_id' => $this->client_id,
|
|
733
|
+ 'logo' => $this->logo,
|
|
734
|
+ 'header' => $this->header,
|
|
735
|
+ 'subheader' => $this->subheader,
|
|
736
|
+ 'invoice_number' => Invoice::getNextDocumentNumber($this->company),
|
|
737
|
+ 'date' => $nextDate,
|
|
738
|
+ 'due_date' => $dueDate,
|
|
739
|
+ 'status' => InvoiceStatus::Draft,
|
|
740
|
+ 'currency_code' => $this->currency_code,
|
|
741
|
+ 'discount_method' => $this->discount_method,
|
|
742
|
+ 'discount_computation' => $this->discount_computation,
|
|
743
|
+ 'discount_rate' => $this->discount_rate,
|
|
744
|
+ 'subtotal' => $this->subtotal,
|
|
745
|
+ 'tax_total' => $this->tax_total,
|
|
746
|
+ 'discount_total' => $this->discount_total,
|
|
747
|
+ 'total' => $this->total,
|
|
748
|
+ 'terms' => $this->terms,
|
|
749
|
+ 'footer' => $this->footer,
|
|
750
|
+ 'created_by' => auth()->id(),
|
|
751
|
+ 'updated_by' => auth()->id(),
|
|
752
|
+ ]);
|
|
753
|
+
|
|
754
|
+ $this->replicateLineItems($invoice);
|
|
755
|
+
|
|
756
|
+ $this->update([
|
|
757
|
+ 'last_date' => $nextDate,
|
|
758
|
+ 'next_date' => $this->calculateNextDate($nextDate),
|
|
759
|
+ 'occurrences_count' => ($this->occurrences_count ?? 0) + 1,
|
|
760
|
+ ]);
|
|
761
|
+
|
|
762
|
+ return $invoice;
|
|
763
|
+ }
|
|
764
|
+
|
|
765
|
+ public function replicateLineItems(Model $target): void
|
|
766
|
+ {
|
|
767
|
+ $this->lineItems->each(function (DocumentLineItem $lineItem) use ($target) {
|
|
768
|
+ $replica = $lineItem->replicate([
|
|
769
|
+ 'documentable_id',
|
|
770
|
+ 'documentable_type',
|
|
771
|
+ 'subtotal',
|
|
772
|
+ 'total',
|
|
773
|
+ 'created_by',
|
|
774
|
+ 'updated_by',
|
|
775
|
+ 'created_at',
|
|
776
|
+ 'updated_at',
|
|
777
|
+ ]);
|
|
778
|
+
|
|
779
|
+ $replica->documentable_id = $target->id;
|
|
780
|
+ $replica->documentable_type = $target->getMorphClass();
|
|
781
|
+ $replica->save();
|
|
782
|
+
|
|
783
|
+ $replica->adjustments()->sync($lineItem->adjustments->pluck('id'));
|
|
784
|
+ });
|
|
785
|
+ }
|
|
786
|
+
|
|
787
|
+ public function shouldGenerateInvoice(): bool
|
|
788
|
+ {
|
|
789
|
+ if (! $this->isActive() || $this->hasReachedEnd()) {
|
|
790
|
+ return false;
|
|
791
|
+ }
|
|
792
|
+
|
|
793
|
+ $nextDate = $this->calculateNextDate();
|
|
794
|
+
|
|
795
|
+ if (! $nextDate || $nextDate->startOfDay()->isFuture()) {
|
|
796
|
+ return false;
|
|
797
|
+ }
|
|
798
|
+
|
|
799
|
+ return true;
|
|
800
|
+ }
|
|
801
|
+
|
|
802
|
+ public function generateDueInvoices(): void
|
|
803
|
+ {
|
|
804
|
+ $maxIterations = 100;
|
|
805
|
+
|
|
806
|
+ for ($i = 0; $i < $maxIterations; $i++) {
|
|
807
|
+ $result = $this->generateInvoice();
|
|
808
|
+
|
|
809
|
+ if (! $result) {
|
|
810
|
+ break;
|
|
811
|
+ }
|
|
812
|
+
|
|
813
|
+ $this->refresh();
|
|
814
|
+ }
|
|
815
|
+ }
|
590
|
816
|
}
|