'datetime', 'ended_at' => 'datetime', 'start_date' => 'date', 'end_date' => 'date', 'next_date' => 'date', 'last_date' => 'date', 'auto_send' => 'boolean', 'send_time' => 'datetime:H:i', 'payment_terms' => PaymentTerms::class, 'frequency' => Frequency::class, 'interval_type' => IntervalType::class, 'interval_value' => 'integer', 'month' => Month::class, 'day_of_month' => DayOfMonth::class, 'day_of_week' => DayOfWeek::class, 'end_type' => EndType::class, 'status' => RecurringInvoiceStatus::class, 'discount_method' => DocumentDiscountMethod::class, 'discount_computation' => AdjustmentComputation::class, 'discount_rate' => RateCast::class, 'subtotal' => MoneyCast::class, 'tax_total' => MoneyCast::class, 'discount_total' => MoneyCast::class, 'total' => MoneyCast::class, ]; public function client(): BelongsTo { return $this->belongsTo(Client::class); } public function invoices(): HasMany { return $this->hasMany(Invoice::class, 'recurring_invoice_id'); } public function documentType(): DocumentType { return DocumentType::RecurringInvoice; } public function documentNumber(): ?string { return 'Auto-generated'; } public function documentDate(): ?string { return $this->calculateNextDate()?->toDefaultDateFormat() ?? 'Auto-generated'; } public function dueDate(): ?string { return $this->calculateNextDueDate()?->toDefaultDateFormat() ?? 'Auto-generated'; } public function referenceNumber(): ?string { return $this->order_number; } public function amountDue(): ?string { return $this->total; } public function isDraft(): bool { return $this->status === RecurringInvoiceStatus::Draft; } public function isActive(): bool { return $this->status === RecurringInvoiceStatus::Active; } public function wasApproved(): bool { return $this->approved_at !== null; } public function wasEnded(): bool { return $this->ended_at !== null; } public function isNeverEnding(): bool { return $this->end_type === EndType::Never; } public function canBeApproved(): bool { return $this->isDraft() && $this->hasSchedule() && ! $this->wasApproved(); } public function canBeEnded(): bool { return $this->isActive() && ! $this->wasEnded(); } public function hasSchedule(): bool { return $this->start_date !== null; } public function getScheduleDescription(): string { $frequency = $this->frequency; return match (true) { $frequency->isDaily() => 'Repeat daily', $frequency->isWeekly() && $this->day_of_week => "Repeat weekly every {$this->day_of_week->getLabel()}", $frequency->isMonthly() && $this->day_of_month => "Repeat monthly on the {$this->day_of_month->getLabel()} day", $frequency->isYearly() && $this->month && $this->day_of_month => "Repeat yearly on {$this->month->getLabel()} {$this->day_of_month->getLabel()}", $frequency->isCustom() => $this->getCustomScheduleDescription(), default => 'Not Configured', }; } private function getCustomScheduleDescription(): string { $interval = $this->interval_value > 1 ? "{$this->interval_value} {$this->interval_type->getPluralLabel()}" : $this->interval_type->getSingularLabel(); $dayDescription = match (true) { $this->interval_type->isWeek() && $this->day_of_week => " on {$this->day_of_week->getLabel()}", $this->interval_type->isMonth() && $this->day_of_month => " on the {$this->day_of_month->getLabel()} day", $this->interval_type->isYear() && $this->month && $this->day_of_month => " on {$this->month->getLabel()} {$this->day_of_month->getLabel()}", default => '' }; return "Repeat every {$interval}{$dayDescription}"; } public function getEndDescription(): string { if (! $this->end_type) { return 'Not configured'; } return match (true) { $this->end_type->isNever() => 'Never', $this->end_type->isAfter() && $this->max_occurrences => "After {$this->max_occurrences} " . str($this->max_occurrences === 1 ? 'invoice' : 'invoices'), $this->end_type->isOn() && $this->end_date => 'On ' . $this->end_date->toDefaultDateFormat(), default => 'Not configured' }; } public function getTimelineDescription(): string { $parts = []; if ($this->start_date) { $parts[] = 'First Invoice: ' . $this->start_date->toDefaultDateFormat(); } if ($this->end_type) { $parts[] = 'Ends: ' . $this->getEndDescription(); } return implode(', ', $parts); } public function calculateNextDate(?Carbon $lastDate = null): ?Carbon { $lastDate ??= $this->last_date; if (! $lastDate && $this->start_date) { return $this->start_date; } if (! $lastDate) { return null; } $nextDate = match (true) { $this->frequency->isDaily() => $lastDate->addDay(), $this->frequency->isWeekly() => $this->calculateNextWeeklyDate($lastDate), $this->frequency->isMonthly() => $this->calculateNextMonthlyDate($lastDate), $this->frequency->isYearly() => $this->calculateNextYearlyDate($lastDate), $this->frequency->isCustom() => $this->calculateCustomNextDate($lastDate), default => null }; if (! $nextDate || $this->hasReachedEnd($nextDate)) { return null; } return $nextDate; } public function calculateNextWeeklyDate(Carbon $lastDate): ?Carbon { return $lastDate->copy()->next($this->day_of_week->name); } public function calculateNextMonthlyDate(Carbon $lastDate): ?Carbon { return $this->day_of_month->resolveDate($lastDate->copy()->addMonth()); } public function calculateNextYearlyDate(Carbon $lastDate): ?Carbon { return $this->day_of_month->resolveDate($lastDate->copy()->addYear()->month($this->month->value)); } protected function calculateCustomNextDate(Carbon $lastDate): ?Carbon { $interval = $this->interval_value ?? 1; return match ($this->interval_type) { IntervalType::Day => $lastDate->copy()->addDays($interval), IntervalType::Week => $lastDate->copy()->addWeeks($interval), IntervalType::Month => $this->day_of_month->resolveDate($lastDate->copy()->addMonths($interval)), IntervalType::Year => $this->day_of_month->resolveDate($lastDate->copy()->addYears($interval)->month($this->month->value)), default => null }; } public function calculateNextDueDate(): ?Carbon { if (! $nextDate = $this->calculateNextDate()) { return null; } if (! $terms = $this->payment_terms) { return $nextDate; } return $nextDate->copy()->addDays($terms->getDays()); } public function hasReachedEnd(?Carbon $nextDate = null): bool { if (! $this->end_type) { return false; } return match (true) { $this->end_type->isNever() => false, $this->end_type->isAfter() => ($this->occurrences_count ?? 0) >= ($this->max_occurrences ?? 0), $this->end_type->isOn() && $this->end_date && $nextDate => $nextDate->greaterThan($this->end_date), default => false }; } public static function getUpdateScheduleAction(string $action = Action::class): MountableAction { return $action::make('updateSchedule') ->label(fn (self $record) => $record->hasSchedule() ? 'Update Schedule' : 'Set Schedule') ->icon('heroicon-o-calendar-date-range') ->slideOver() ->successNotificationTitle('Schedule Updated') ->mountUsing(function (self $record, Form $form) { $data = $record->attributesToArray(); $data['day_of_month'] ??= DayOfMonth::First; $data['start_date'] ??= now()->addMonth()->startOfMonth(); $form->fill($data); }) ->form([ CustomSection::make('Frequency') ->contained(false) ->schema(function (Forms\Get $get) { $frequency = Frequency::parse($get('frequency')); $intervalType = IntervalType::parse($get('interval_type')); $month = Month::parse($get('month')); $dayOfMonth = DayOfMonth::parse($get('day_of_month')); return [ Forms\Components\Select::make('frequency') ->label('Repeats') ->options(Frequency::class) ->softRequired() ->live() ->afterStateUpdated(function (Forms\Set $set, $state) { $handler = new ScheduleHandler($set); $handler->handleFrequencyChange($state); }), // Custom frequency fields in a nested grid Cluster::make([ Forms\Components\TextInput::make('interval_value') ->softRequired() ->numeric() ->default(1), Forms\Components\Select::make('interval_type') ->options(IntervalType::class) ->softRequired() ->default(IntervalType::Month) ->live() ->afterStateUpdated(function (Forms\Set $set, $state) { $handler = new ScheduleHandler($set); $handler->handleIntervalTypeChange($state); }), ]) ->live() ->label('Every') ->required() ->markAsRequired(false) ->visible($frequency->isCustom()), // Specific schedule details Forms\Components\Select::make('month') ->label('Month') ->options(Month::class) ->softRequired() ->visible($frequency->isYearly() || $intervalType?->isYear()) ->live() ->afterStateUpdated(function (Forms\Set $set, Forms\Get $get, $state) { $handler = new ScheduleHandler($set, $get); $handler->handleDateChange('month', $state); }), Forms\Components\Select::make('day_of_month') ->label('Day of Month') ->options(function () use ($month) { if (! $month) { return DayOfMonth::class; } $daysInMonth = Carbon::createFromDate(null, $month->value)->daysInMonth; return collect(DayOfMonth::cases()) ->filter(static fn (DayOfMonth $dayOfMonth) => $dayOfMonth->value <= $daysInMonth || $dayOfMonth->isLast()) ->mapWithKeys(fn (DayOfMonth $dayOfMonth) => [$dayOfMonth->value => $dayOfMonth->getLabel()]); }) ->softRequired() ->visible(in_array($frequency, [Frequency::Monthly, Frequency::Yearly]) || in_array($intervalType, [IntervalType::Month, IntervalType::Year])) ->live() ->afterStateUpdated(function (Forms\Set $set, Forms\Get $get, $state) { $handler = new ScheduleHandler($set, $get); $handler->handleDateChange('day_of_month', $state); }), SimpleAlert::make('dayOfMonthNotice') ->title(function () use ($dayOfMonth) { return "The invoice will be created on the {$dayOfMonth->getLabel()} day of each month, or on the last day for months ending earlier."; }) ->columnSpanFull() ->visible($dayOfMonth?->value > 28), Forms\Components\Select::make('day_of_week') ->label('Day of Week') ->options(DayOfWeek::class) ->softRequired() ->visible($frequency->isWeekly() || $intervalType?->isWeek()) ->live() ->afterStateUpdated(function (Forms\Set $set, $state) { $handler = new ScheduleHandler($set); $handler->handleDateChange('day_of_week', $state); }), ]; })->columns(2), CustomSection::make('Dates & Time') ->contained(false) ->schema([ Forms\Components\DatePicker::make('start_date') ->label('First Invoice Date') ->softRequired() ->live() ->minDate(today()) ->closeOnDateSelection() ->afterStateUpdated(function (Forms\Set $set, Forms\Get $get, $state) { $handler = new ScheduleHandler($set, $get); $handler->handleDateChange('start_date', $state); }), Forms\Components\Group::make(function (Forms\Get $get) { $components = []; $components[] = Forms\Components\Select::make('end_type') ->label('End Schedule') ->options(EndType::class) ->softRequired() ->live() ->afterStateUpdated(function (Forms\Set $set, $state) { $endType = EndType::parse($state); $set('max_occurrences', $endType?->isAfter() ? 1 : null); $set('end_date', $endType?->isOn() ? now()->addMonth()->startOfMonth() : null); }); $endType = EndType::parse($get('end_type')); if ($endType?->isAfter()) { $components[] = Forms\Components\TextInput::make('max_occurrences') ->numeric() ->suffix('invoices') ->live(); } if ($endType?->isOn()) { $components[] = Forms\Components\DatePicker::make('end_date') ->live(); } return [ Cluster::make($components) ->label('Schedule Ends') ->required() ->markAsRequired(false), ]; }), Forms\Components\Select::make('timezone') ->options(Timezone::getTimezoneOptions(CompanyProfile::first()->country)) ->searchable() ->softRequired(), ]) ->columns(2), ]) ->action(function (self $record, array $data, MountableAction $action) { $record->update($data); $action->success(); }); } public static function getApproveDraftAction(string $action = Action::class): MountableAction { return $action::make('approveDraft') ->label('Approve') ->icon('heroicon-o-check-circle') ->visible(function (self $record) { return $record->canBeApproved(); }) ->databaseTransaction() ->successNotificationTitle('Recurring Invoice Approved') ->action(function (self $record, MountableAction $action) { $record->approveDraft(); $action->success(); }); } public function approveDraft(?Carbon $approvedAt = null): void { if (! $this->isDraft()) { throw new \RuntimeException('Invoice is not in draft status.'); } $approvedAt ??= now(); $this->update([ 'approved_at' => $approvedAt, 'status' => RecurringInvoiceStatus::Active, ]); } public function generateInvoice(): ?Invoice { if (! $this->shouldGenerateInvoice()) { return null; } $nextDate = $this->next_date ?? $this->calculateNextDate(); if (! $nextDate) { return null; } $dueDate = $this->calculateNextDueDate(); $invoice = $this->invoices()->create([ 'company_id' => $this->company_id, 'client_id' => $this->client_id, 'logo' => $this->logo, 'header' => $this->header, 'subheader' => $this->subheader, 'invoice_number' => Invoice::getNextDocumentNumber($this->company), 'date' => $nextDate, 'due_date' => $dueDate, 'status' => InvoiceStatus::Draft, 'currency_code' => $this->currency_code, 'discount_method' => $this->discount_method, 'discount_computation' => $this->discount_computation, 'discount_rate' => $this->discount_rate, 'subtotal' => $this->subtotal, 'tax_total' => $this->tax_total, 'discount_total' => $this->discount_total, 'total' => $this->total, 'terms' => $this->terms, 'footer' => $this->footer, 'created_by' => auth()->id(), 'updated_by' => auth()->id(), ]); $this->replicateLineItems($invoice); $this->update([ 'last_date' => $nextDate, 'next_date' => $this->calculateNextDate($nextDate), 'occurrences_count' => ($this->occurrences_count ?? 0) + 1, ]); return $invoice; } public function replicateLineItems(Model $target): void { $this->lineItems->each(function (DocumentLineItem $lineItem) use ($target) { $replica = $lineItem->replicate([ 'documentable_id', 'documentable_type', 'subtotal', 'total', 'created_by', 'updated_by', 'created_at', 'updated_at', ]); $replica->documentable_id = $target->id; $replica->documentable_type = $target->getMorphClass(); $replica->save(); $replica->adjustments()->sync($lineItem->adjustments->pluck('id')); }); } public function shouldGenerateInvoice(): bool { if (! $this->isActive() || $this->hasReachedEnd()) { return false; } $nextDate = $this->calculateNextDate(); if (! $nextDate || $nextDate->startOfDay()->isFuture()) { return false; } return true; } public function generateDueInvoices(): void { $maxIterations = 100; for ($i = 0; $i < $maxIterations; $i++) { $result = $this->generateInvoice(); if (! $result) { break; } $this->refresh(); } } }