|
@@ -19,14 +19,18 @@ use App\Enums\Accounting\RecurringInvoiceStatus;
|
19
|
19
|
use App\Enums\Setting\PaymentTerms;
|
20
|
20
|
use App\Models\Common\Client;
|
21
|
21
|
use App\Models\Setting\Currency;
|
|
22
|
+use App\Observers\RecurringInvoiceObserver;
|
22
|
23
|
use Illuminate\Database\Eloquent\Attributes\CollectedBy;
|
|
24
|
+use Illuminate\Database\Eloquent\Attributes\ObservedBy;
|
23
|
25
|
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
24
|
26
|
use Illuminate\Database\Eloquent\Model;
|
25
|
27
|
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
26
|
28
|
use Illuminate\Database\Eloquent\Relations\HasMany;
|
27
|
29
|
use Illuminate\Database\Eloquent\Relations\MorphMany;
|
|
30
|
+use Illuminate\Support\Carbon;
|
28
|
31
|
|
29
|
32
|
#[CollectedBy(DocumentCollection::class)]
|
|
33
|
+#[ObservedBy(RecurringInvoiceObserver::class)]
|
30
|
34
|
class RecurringInvoice extends Model
|
31
|
35
|
{
|
32
|
36
|
use Blamable;
|
|
@@ -161,4 +165,156 @@ class RecurringInvoice extends Model
|
161
|
165
|
{
|
162
|
166
|
return $this->lineItems()->exists();
|
163
|
167
|
}
|
|
168
|
+
|
|
169
|
+ public function getScheduleDescription(): string
|
|
170
|
+ {
|
|
171
|
+ $frequency = $this->frequency;
|
|
172
|
+
|
|
173
|
+ return match (true) {
|
|
174
|
+ $frequency->isDaily() => 'Repeat daily',
|
|
175
|
+
|
|
176
|
+ $frequency->isWeekly() && $this->day_of_week => "Repeat weekly every {$this->day_of_week->getLabel()}",
|
|
177
|
+
|
|
178
|
+ $frequency->isMonthly() && $this->day_of_month => "Repeat monthly on the {$this->day_of_month->getLabel()} day",
|
|
179
|
+
|
|
180
|
+ $frequency->isYearly() && $this->month && $this->day_of_month => "Repeat yearly on {$this->month->getLabel()} {$this->day_of_month->getLabel()}",
|
|
181
|
+
|
|
182
|
+ $frequency->isCustom() => $this->getCustomScheduleDescription(),
|
|
183
|
+
|
|
184
|
+ default => 'Schedule not configured'
|
|
185
|
+ };
|
|
186
|
+ }
|
|
187
|
+
|
|
188
|
+ private function getCustomScheduleDescription(): string
|
|
189
|
+ {
|
|
190
|
+ $interval = $this->interval_value > 1
|
|
191
|
+ ? "{$this->interval_value} {$this->interval_type->getPluralLabel()}"
|
|
192
|
+ : $this->interval_type->getSingularLabel();
|
|
193
|
+
|
|
194
|
+ $dayDescription = match (true) {
|
|
195
|
+ $this->interval_type->isWeek() && $this->day_of_week => " on {$this->day_of_week->getLabel()}",
|
|
196
|
+
|
|
197
|
+ $this->interval_type->isMonth() && $this->day_of_month => " on the {$this->day_of_month->getLabel()} day",
|
|
198
|
+
|
|
199
|
+ $this->interval_type->isYear() && $this->month && $this->day_of_month => " on {$this->month->getLabel()} {$this->day_of_month->getLabel()}",
|
|
200
|
+
|
|
201
|
+ default => ''
|
|
202
|
+ };
|
|
203
|
+
|
|
204
|
+ return "Repeat every {$interval}{$dayDescription}";
|
|
205
|
+ }
|
|
206
|
+
|
|
207
|
+ /**
|
|
208
|
+ * Get a human-readable description of when the schedule ends.
|
|
209
|
+ */
|
|
210
|
+ public function getEndDescription(): string
|
|
211
|
+ {
|
|
212
|
+ if (! $this->end_type) {
|
|
213
|
+ return 'Not configured';
|
|
214
|
+ }
|
|
215
|
+
|
|
216
|
+ return match (true) {
|
|
217
|
+ $this->end_type->isNever() => 'Never',
|
|
218
|
+
|
|
219
|
+ $this->end_type->isAfter() && $this->max_occurrences => "After {$this->max_occurrences} " . str($this->max_occurrences === 1 ? 'invoice' : 'invoices'),
|
|
220
|
+
|
|
221
|
+ $this->end_type->isOn() && $this->end_date => 'On ' . $this->end_date->toDefaultDateFormat(),
|
|
222
|
+
|
|
223
|
+ default => 'Not configured'
|
|
224
|
+ };
|
|
225
|
+ }
|
|
226
|
+
|
|
227
|
+ /**
|
|
228
|
+ * Get the schedule timeline description.
|
|
229
|
+ */
|
|
230
|
+ public function getTimelineDescription(): string
|
|
231
|
+ {
|
|
232
|
+ $parts = [];
|
|
233
|
+
|
|
234
|
+ if ($this->start_date) {
|
|
235
|
+ $parts[] = 'First Invoice: ' . $this->start_date->toDefaultDateFormat();
|
|
236
|
+ }
|
|
237
|
+
|
|
238
|
+ if ($this->end_type) {
|
|
239
|
+ $parts[] = 'Ends: ' . $this->getEndDescription();
|
|
240
|
+ }
|
|
241
|
+
|
|
242
|
+ return implode(', ', $parts);
|
|
243
|
+ }
|
|
244
|
+
|
|
245
|
+ /**
|
|
246
|
+ * Get next occurrence date based on the schedule.
|
|
247
|
+ */
|
|
248
|
+ public function calculateNextDate(): ?\Carbon\Carbon
|
|
249
|
+ {
|
|
250
|
+ $lastDate = $this->last_date ?? $this->start_date;
|
|
251
|
+ if (! $lastDate) {
|
|
252
|
+ return null;
|
|
253
|
+ }
|
|
254
|
+
|
|
255
|
+ $nextDate = match (true) {
|
|
256
|
+ $this->frequency->isDaily() => $lastDate->addDay(),
|
|
257
|
+
|
|
258
|
+ $this->frequency->isWeekly() => $lastDate->addWeek(),
|
|
259
|
+
|
|
260
|
+ $this->frequency->isMonthly() => $lastDate->addMonth(),
|
|
261
|
+
|
|
262
|
+ $this->frequency->isYearly() => $lastDate->addYear(),
|
|
263
|
+
|
|
264
|
+ $this->frequency->isCustom() => $this->calculateCustomNextDate($lastDate),
|
|
265
|
+
|
|
266
|
+ default => null
|
|
267
|
+ };
|
|
268
|
+
|
|
269
|
+ // Check if we've reached the end
|
|
270
|
+ if ($this->hasReachedEnd($nextDate)) {
|
|
271
|
+ return null;
|
|
272
|
+ }
|
|
273
|
+
|
|
274
|
+ return $nextDate;
|
|
275
|
+ }
|
|
276
|
+
|
|
277
|
+ /**
|
|
278
|
+ * Calculate next date for custom intervals
|
|
279
|
+ */
|
|
280
|
+ protected function calculateCustomNextDate(Carbon $lastDate): ?\Carbon\Carbon
|
|
281
|
+ {
|
|
282
|
+ $value = $this->interval_value ?? 1;
|
|
283
|
+
|
|
284
|
+ return match ($this->interval_type) {
|
|
285
|
+ IntervalType::Day => $lastDate->addDays($value),
|
|
286
|
+ IntervalType::Week => $lastDate->addWeeks($value),
|
|
287
|
+ IntervalType::Month => $lastDate->addMonths($value),
|
|
288
|
+ IntervalType::Year => $lastDate->addYears($value),
|
|
289
|
+ default => null
|
|
290
|
+ };
|
|
291
|
+ }
|
|
292
|
+
|
|
293
|
+ /**
|
|
294
|
+ * Check if the schedule has reached its end
|
|
295
|
+ */
|
|
296
|
+ public function hasReachedEnd(?Carbon $nextDate = null): bool
|
|
297
|
+ {
|
|
298
|
+ if (! $this->end_type) {
|
|
299
|
+ return false;
|
|
300
|
+ }
|
|
301
|
+
|
|
302
|
+ return match (true) {
|
|
303
|
+ $this->end_type->isNever() => false,
|
|
304
|
+
|
|
305
|
+ $this->end_type->isAfter() => ($this->occurrences_count ?? 0) >= ($this->max_occurrences ?? 0),
|
|
306
|
+
|
|
307
|
+ $this->end_type->isOn() && $this->end_date && $nextDate => $nextDate->greaterThan($this->end_date),
|
|
308
|
+
|
|
309
|
+ default => false
|
|
310
|
+ };
|
|
311
|
+ }
|
|
312
|
+
|
|
313
|
+ public function markAsApproved(): void
|
|
314
|
+ {
|
|
315
|
+ $this->update([
|
|
316
|
+ 'approved_at' => now(),
|
|
317
|
+ 'status' => RecurringInvoiceStatus::Active,
|
|
318
|
+ ]);
|
|
319
|
+ }
|
164
|
320
|
}
|