diff --git a/app/Console/Commands/GenerateRecurringInvoices.php b/app/Console/Commands/GenerateRecurringInvoices.php new file mode 100644 index 00000000..04b50e65 --- /dev/null +++ b/app/Console/Commands/GenerateRecurringInvoices.php @@ -0,0 +1,25 @@ +generateDueInvoices(); + + $this->info("Generated {$generated} recurring invoice(s)."); + + return self::SUCCESS; + } +} diff --git a/app/Http/Controllers/Company/RecurringInvoice/RecurringInvoiceFrequencyController.php b/app/Http/Controllers/Company/RecurringInvoice/RecurringInvoiceFrequencyController.php index b98fea63..1ed12f2a 100644 --- a/app/Http/Controllers/Company/RecurringInvoice/RecurringInvoiceFrequencyController.php +++ b/app/Http/Controllers/Company/RecurringInvoice/RecurringInvoiceFrequencyController.php @@ -3,14 +3,15 @@ namespace App\Http\Controllers\Company\RecurringInvoice; use App\Http\Controllers\Controller; -use App\Models\RecurringInvoice; -use Illuminate\Http\Request; +use App\Http\Requests\RecurringInvoiceFrequencyRequest; +use App\Services\Document\RecurringInvoiceScheduleService; class RecurringInvoiceFrequencyController extends Controller { - public function __invoke(Request $request) + public function __invoke(RecurringInvoiceFrequencyRequest $request, RecurringInvoiceScheduleService $schedule) { - $nextInvoiceAt = RecurringInvoice::getNextInvoiceDate($request->frequency, $request->starts_at); + $nextInvoiceAt = $schedule->firstFutureOccurrence($request->frequency, $request->starts_at, (int) $request->header('company')) + ->format('Y-m-d H:i:s'); return response()->json([ 'success' => true, diff --git a/app/Http/Requests/RecurringInvoiceFrequencyRequest.php b/app/Http/Requests/RecurringInvoiceFrequencyRequest.php new file mode 100644 index 00000000..9729ec70 --- /dev/null +++ b/app/Http/Requests/RecurringInvoiceFrequencyRequest.php @@ -0,0 +1,31 @@ +|string> + */ + public function rules(): array + { + return [ + 'frequency' => ['required', 'string', new ValidCronExpression], + 'starts_at' => ['required', 'date'], + ]; + } +} diff --git a/app/Http/Requests/RecurringInvoiceRequest.php b/app/Http/Requests/RecurringInvoiceRequest.php index ebb025d6..0430ba20 100644 --- a/app/Http/Requests/RecurringInvoiceRequest.php +++ b/app/Http/Requests/RecurringInvoiceRequest.php @@ -4,7 +4,8 @@ namespace App\Http\Requests; use App\Models\CompanySetting; use App\Models\Customer; -use App\Models\RecurringInvoice; +use App\Rules\ValidCronExpression; +use App\Services\Document\RecurringInvoiceScheduleService; use App\Support\DocumentTotals; use Illuminate\Foundation\Http\FormRequest; @@ -28,6 +29,7 @@ class RecurringInvoiceRequest extends FormRequest $rules = [ 'starts_at' => [ 'required', + 'date', ], 'send_automatically' => [ 'required', @@ -67,6 +69,8 @@ class RecurringInvoiceRequest extends FormRequest ], 'frequency' => [ 'required', + 'string', + new ValidCronExpression, ], 'limit_by' => [ 'required', @@ -108,7 +112,10 @@ class RecurringInvoiceRequest extends FormRequest $exchange_rate = $company_currency != $current_currency ? $this->exchange_rate : 1; $currency = Customer::find($this->customer_id)->currency_id; - $nextInvoiceAt = RecurringInvoice::getNextInvoiceDate($this->frequency, $this->starts_at); + $schedule = app(RecurringInvoiceScheduleService::class); + $nextInvoiceAt = $schedule->toStored( + $schedule->firstFutureOccurrence($this->frequency, $this->starts_at, (int) $this->header('company')) + ); $tax_per_item = CompanySetting::getSetting('tax_per_item', $this->header('company')) ?? 'NO '; $discount_per_item = CompanySetting::getSetting('discount_per_item', $this->header('company')) ?? 'NO'; diff --git a/app/Http/Resources/RecurringInvoiceResource.php b/app/Http/Resources/RecurringInvoiceResource.php index 4c182b67..1a73d281 100644 --- a/app/Http/Resources/RecurringInvoiceResource.php +++ b/app/Http/Resources/RecurringInvoiceResource.php @@ -2,6 +2,7 @@ namespace App\Http\Resources; +use App\Services\Document\RecurringInvoiceScheduleService; use Illuminate\Http\Request; use Illuminate\Http\Resources\Json\JsonResource; @@ -26,7 +27,11 @@ class RecurringInvoiceResource extends JsonResource 'company_id' => $this->company_id, 'creator_id' => $this->creator_id, 'status' => $this->status, - 'next_invoice_at' => $this->next_invoice_at, + 'next_invoice_at' => $this->next_invoice_at + ? app(RecurringInvoiceScheduleService::class) + ->fromStored($this->next_invoice_at, $this->company_id) + ->format('Y-m-d H:i:s') + : null, 'frequency' => $this->frequency, 'limit_by' => $this->limit_by, 'limit_count' => $this->limit_count, diff --git a/app/Models/RecurringInvoice.php b/app/Models/RecurringInvoice.php index 2b8297ab..2712e893 100644 --- a/app/Models/RecurringInvoice.php +++ b/app/Models/RecurringInvoice.php @@ -2,10 +2,10 @@ namespace App\Models; +use App\Services\Document\RecurringInvoiceScheduleService; use App\Support\SafeOrderBy; use App\Traits\HasCustomFieldsTrait; use Carbon\Carbon; -use Cron; use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Relations\BelongsTo; @@ -60,9 +60,15 @@ class RecurringInvoice extends Model public function getFormattedNextInvoiceAtAttribute() { + if (! $this->next_invoice_at) { + return null; + } + $dateFormat = CompanySetting::getSetting('carbon_date_format', $this->company_id); - return Carbon::parse($this->next_invoice_at)->translatedFormat($dateFormat); + return app(RecurringInvoiceScheduleService::class) + ->fromStored($this->next_invoice_at, $this->company_id) + ->translatedFormat($dateFormat); } public function getFormattedLimitDateAttribute() @@ -190,28 +196,4 @@ class RecurringInvoice extends Model $query->whereOrder($field, $orderBy); } } - - public function markStatusAsCompleted(): void - { - if ($this->status == $this->status) { - $this->status = self::COMPLETED; - $this->save(); - } - } - - public static function getNextInvoiceDate(string $frequency, string $starts_at): string - { - $cron = new Cron\CronExpression($frequency); - $timezone = config('app.timezone', 'UTC'); - - return $cron->getNextRunDate($starts_at, 0, false, $timezone)->format('Y-m-d H:i:s'); - } - - public function updateNextInvoiceDate(): void - { - $nextInvoiceAt = self::getNextInvoiceDate($this->frequency, $this->starts_at); - - $this->next_invoice_at = $nextInvoiceAt; - $this->save(); - } } diff --git a/app/Rules/ValidCronExpression.php b/app/Rules/ValidCronExpression.php new file mode 100644 index 00000000..4108fda8 --- /dev/null +++ b/app/Rules/ValidCronExpression.php @@ -0,0 +1,23 @@ + */ + private array $companyTimezones = []; + + public function companyTimezone(int $companyId): string + { + if (isset($this->companyTimezones[$companyId])) { + return $this->companyTimezones[$companyId]; + } + + $timezone = CompanySetting::getSetting('time_zone', $companyId) ?: config('app.timezone', 'UTC'); + + try { + new DateTimeZone($timezone); + } catch (\Exception) { + $timezone = $this->applicationTimezone(); + } + + return $this->companyTimezones[$companyId] = $timezone; + } + + public function applicationTimezone(): string + { + $timezone = config('app.timezone', 'UTC'); + + try { + new DateTimeZone($timezone); + + return $timezone; + } catch (\Exception) { + return 'UTC'; + } + } + + public function firstFutureOccurrence(string $frequency, string $startsAt, int $companyId, ?Carbon $now = null): Carbon + { + $timezone = $this->companyTimezone($companyId); + $start = Carbon::parse($startsAt, $timezone); + $now = ($now ?: Carbon::now($this->applicationTimezone()))->copy()->setTimezone($timezone); + + if ($start->greaterThan($now) + && $start->second === 0 + && (new CronExpression($frequency))->isDue($start, $timezone)) { + return $start; + } + + return $this->nextOccurrence($frequency, $start->greaterThan($now) ? $start : $now, $timezone); + } + + public function nextOccurrence(string $frequency, Carbon $after, string $timezone): Carbon + { + $cron = new CronExpression($frequency); + + return Carbon::instance($cron->getNextRunDate($after, 0, false, $timezone)) + ->setTimezone($timezone); + } + + public function fromStored(string $date, int $companyId): Carbon + { + return Carbon::parse($date, $this->applicationTimezone()) + ->setTimezone($this->companyTimezone($companyId)); + } + + public function toStored(Carbon $date): string + { + return $date->copy()->setTimezone($this->applicationTimezone())->format('Y-m-d H:i:s'); + } +} diff --git a/app/Services/Document/RecurringInvoiceService.php b/app/Services/Document/RecurringInvoiceService.php index 661523aa..9b602360 100644 --- a/app/Services/Document/RecurringInvoiceService.php +++ b/app/Services/Document/RecurringInvoiceService.php @@ -12,12 +12,22 @@ use App\Models\Invoice; use App\Models\RecurringInvoice; use Carbon\Carbon; use Illuminate\Support\Collection; +use Illuminate\Support\Facades\DB; +use Illuminate\Support\Facades\Log; +use Throwable; class RecurringInvoiceService { + private const MAX_INVOICES_PER_RUN = 100; + + private const MAX_INVOICES_PER_TEMPLATE = 10; + + private const MAX_TEMPLATES_PER_RUN = 100; + public function __construct( private readonly DocumentItemService $documentItemService, private readonly InvoiceService $invoiceService, + private readonly RecurringInvoiceScheduleService $schedule, ) {} public function create(RecurringInvoiceRequest $request): RecurringInvoice @@ -93,44 +103,120 @@ class RecurringInvoiceService return true; } - public function generateInvoice(RecurringInvoice $recurringInvoice): void + public function generateDueInvoices(): int { - if (Carbon::now()->lessThan($recurringInvoice->starts_at)) { - return; + $now = Carbon::now($this->schedule->applicationTimezone()); + $nowString = $now->format('Y-m-d H:i:s'); + $dueTemplateIds = RecurringInvoice::query() + ->where('status', RecurringInvoice::ACTIVE) + ->whereNotNull('next_invoice_at') + ->where('next_invoice_at', '<=', $nowString) + ->orderBy('next_invoice_at') + ->orderBy('id') + ->limit(self::MAX_TEMPLATES_PER_RUN) + ->pluck('id'); + + $generated = 0; + $finishedTemplateIds = []; + + // Visit every due template once before starting another pass. This lets a + // busy, long-overdue template catch up without starving the rest. + for ($round = 0; $round < self::MAX_INVOICES_PER_TEMPLATE && $generated < self::MAX_INVOICES_PER_RUN; $round++) { + foreach ($dueTemplateIds as $id) { + if ($generated >= self::MAX_INVOICES_PER_RUN) { + break 2; + } + + if (isset($finishedTemplateIds[$id])) { + continue; + } + + try { + $invoice = $this->generateDueInvoice((int) $id, $now); + + if ($invoice) { + $generated++; + $this->sendAutomatically($invoice); + } else { + $finishedTemplateIds[$id] = true; + } + } catch (Throwable $exception) { + // A broken template remains due for the next invocation, but + // retrying it in every catch-up round would only duplicate + // work and log noise while starving healthy templates. + $finishedTemplateIds[$id] = true; + + Log::error('Unable to generate recurring invoice.', [ + 'recurring_invoice_id' => $id, + 'exception' => $exception, + ]); + } + } } - if ($recurringInvoice->limit_by == 'DATE') { - $startDate = Carbon::today()->format('Y-m-d'); - $endDate = $recurringInvoice->limit_date; - - if ($endDate >= $startDate) { - $this->createInvoiceFromRecurring($recurringInvoice); - $recurringInvoice->updateNextInvoiceDate(); - } else { - $recurringInvoice->markStatusAsCompleted(); - } - } elseif ($recurringInvoice->limit_by == 'COUNT') { - $invoiceCount = Invoice::where('recurring_invoice_id', $recurringInvoice->id)->count(); - - if ($invoiceCount < $recurringInvoice->limit_count) { - $this->createInvoiceFromRecurring($recurringInvoice); - $recurringInvoice->updateNextInvoiceDate(); - } else { - $recurringInvoice->markStatusAsCompleted(); - } - } else { - $this->createInvoiceFromRecurring($recurringInvoice); - $recurringInvoice->updateNextInvoiceDate(); - } + return $generated; } - private function createInvoiceFromRecurring(RecurringInvoice $recurringInvoice): void + private function generateDueInvoice(int $recurringInvoiceId, Carbon $now): ?Invoice + { + return DB::transaction(function () use ($recurringInvoiceId, $now) { + $recurringInvoice = RecurringInvoice::query() + ->lockForUpdate() + ->find($recurringInvoiceId); + + if (! $recurringInvoice + || $recurringInvoice->status !== RecurringInvoice::ACTIVE + || ! $recurringInvoice->next_invoice_at + || $recurringInvoice->next_invoice_at > $now->format('Y-m-d H:i:s')) { + return null; + } + + $timezone = $this->schedule->companyTimezone($recurringInvoice->company_id); + $occurrence = $this->schedule->fromStored($recurringInvoice->next_invoice_at, $recurringInvoice->company_id); + $invoiceCount = null; + + if ($recurringInvoice->limit_by === RecurringInvoice::DATE + && (! $recurringInvoice->limit_date || $occurrence->toDateString() > $recurringInvoice->limit_date)) { + $recurringInvoice->update(['status' => RecurringInvoice::COMPLETED]); + + return null; + } + + if ($recurringInvoice->limit_by === RecurringInvoice::COUNT) { + $invoiceCount = $recurringInvoice->invoices()->count(); + + if (! $recurringInvoice->limit_count || $invoiceCount >= $recurringInvoice->limit_count) { + $recurringInvoice->update(['status' => RecurringInvoice::COMPLETED]); + + return null; + } + } + + $invoice = $this->createInvoiceFromRecurring($recurringInvoice, $occurrence); + $nextOccurrence = $this->schedule->nextOccurrence($recurringInvoice->frequency, $occurrence, $timezone); + + $complete = ($recurringInvoice->limit_by === RecurringInvoice::COUNT + && $invoiceCount + 1 >= $recurringInvoice->limit_count) + || ($recurringInvoice->limit_by === RecurringInvoice::DATE + && $nextOccurrence->toDateString() > $recurringInvoice->limit_date); + + $recurringInvoice->update([ + 'next_invoice_at' => $this->schedule->toStored($nextOccurrence), + 'status' => $complete ? RecurringInvoice::COMPLETED : RecurringInvoice::ACTIVE, + ]); + + return $invoice; + }); + } + + private function createInvoiceFromRecurring(RecurringInvoice $recurringInvoice, Carbon $occurrence): Invoice { $serial = (new SerialNumberService) ->setModel(new Invoice) ->setCompany($recurringInvoice->company_id) ->setCustomer($recurringInvoice->customer_id) ->setSequenceScope(['type' => Invoice::TYPE_INVOICE]) + ->setOccurrenceDate($occurrence) ->setNextNumbers(); $days = intval(CompanySetting::getSetting('invoice_due_date_days', $recurringInvoice->company_id)); @@ -140,9 +226,10 @@ class RecurringInvoiceService } $newInvoice['creator_id'] = $recurringInvoice->creator_id; - $newInvoice['invoice_date'] = Carbon::today()->format('Y-m-d'); - $newInvoice['due_date'] = Carbon::today()->addDays($days)->format('Y-m-d'); + $newInvoice['invoice_date'] = $occurrence->toDateString(); + $newInvoice['due_date'] = $occurrence->copy()->addDays($days)->toDateString(); $newInvoice['status'] = Invoice::STATUS_DRAFT; + $newInvoice['type'] = Invoice::TYPE_INVOICE; $newInvoice['company_id'] = $recurringInvoice->company_id; $newInvoice['paid_status'] = Invoice::STATUS_UNPAID; $newInvoice['sub_total'] = $recurringInvoice->sub_total; @@ -194,18 +281,33 @@ class RecurringInvoiceService $invoice->addCustomFields($customField); } - if ($recurringInvoice->send_automatically == true) { - $data = [ - 'body' => CompanySetting::getSetting('invoice_mail_body', $recurringInvoice->company_id), + return $invoice; + } + + private function sendAutomatically(Invoice $invoice): void + { + $recurringInvoice = $invoice->recurringInvoice; + + if (! $recurringInvoice?->send_automatically) { + return; + } + + try { + $this->invoiceService->send($invoice, [ + 'body' => CompanySetting::getSetting('invoice_mail_body', $invoice->company_id), 'from' => config('mail.from.address'), - 'to' => $recurringInvoice->customer->email, + 'to' => $invoice->customer->email, 'subject' => trans('invoices')['new_invoice'], 'invoice' => $invoice->toArray(), 'customer' => $invoice->customer->toArray(), 'company' => Company::find($invoice->company_id), - ]; - - $this->invoiceService->send($invoice, $data); + ]); + } catch (Throwable $exception) { + Log::error('Unable to send automatically generated recurring invoice.', [ + 'invoice_id' => $invoice->id, + 'recurring_invoice_id' => $recurringInvoice->id, + 'exception' => $exception, + ]); } } diff --git a/app/Services/Document/SerialNumberService.php b/app/Services/Document/SerialNumberService.php index ab9519e7..8d28e092 100644 --- a/app/Services/Document/SerialNumberService.php +++ b/app/Services/Document/SerialNumberService.php @@ -4,6 +4,7 @@ namespace App\Services\Document; use App\Models\CompanySetting; use App\Models\Customer; +use Carbon\Carbon; class SerialNumberService { @@ -21,6 +22,8 @@ class SerialNumberService private $sequenceScope = []; + private ?Carbon $occurrenceDate = null; + /** * @var string */ @@ -108,6 +111,13 @@ class SerialNumberService return $this; } + public function setOccurrenceDate(Carbon $date): self + { + $this->occurrenceDate = $date; + + return $this; + } + /** * @return string */ @@ -241,7 +251,7 @@ class SerialNumberService break; case 'DATE_FORMAT': $value = $value ? $value : 'Y'; - $serialNumber .= date($value); + $serialNumber .= ($this->occurrenceDate ?: Carbon::now())->format($value); break; case 'RANDOM_SEQUENCE': diff --git a/database/migrations/2026_08_02_203755_add_due_index_and_normalize_recurring_invoices.php b/database/migrations/2026_08_02_203755_add_due_index_and_normalize_recurring_invoices.php new file mode 100644 index 00000000..54f568db --- /dev/null +++ b/database/migrations/2026_08_02_203755_add_due_index_and_normalize_recurring_invoices.php @@ -0,0 +1,92 @@ +where('status', 'ACTIVE') + ->orderBy('id') + ->chunkById(500, function ($recurringInvoices) use ($applicationTimezone, $cutover): void { + $timezones = DB::table('company_settings') + ->whereIn('company_id', $recurringInvoices->pluck('company_id')->unique()) + ->where('option', 'time_zone') + ->pluck('value', 'company_id'); + + foreach ($recurringInvoices as $recurringInvoice) { + $timezone = $timezones->get($recurringInvoice->company_id) ?: $applicationTimezone; + + try { + new DateTimeZone($timezone); + } catch (Exception) { + $timezone = $applicationTimezone; + } + + try { + $cron = new CronExpression($recurringInvoice->frequency); + $startsAt = Carbon::parse($recurringInvoice->starts_at, $timezone); + $reference = $startsAt->greaterThan($cutover->copy()->setTimezone($timezone)) + ? $startsAt + : $cutover->copy()->setTimezone($timezone); + $next = $reference->greaterThan($cutover->copy()->setTimezone($timezone)) + && $reference->second === 0 + && $cron->isDue($reference, $timezone) + ? $reference + : Carbon::instance($cron->getNextRunDate($reference, 0, false, $timezone)); + } catch (Throwable $exception) { + DB::table('recurring_invoices') + ->where('id', $recurringInvoice->id) + ->update([ + 'status' => 'ON_HOLD', + ]); + + Log::warning('Put recurring invoice on hold during scheduler cutover because its schedule is invalid.', [ + 'recurring_invoice_id' => $recurringInvoice->id, + 'exception' => $exception, + ]); + + continue; + } + + // Keep database write failures outside the cron/date error + // boundary. A systemic migration failure must abort instead + // of silently putting otherwise valid templates on hold. + DB::table('recurring_invoices') + ->where('id', $recurringInvoice->id) + ->update([ + 'next_invoice_at' => $next->setTimezone($applicationTimezone)->format('Y-m-d H:i:s'), + ]); + } + }); + + // Add the index after the data pass. On databases whose DDL is not + // transactional, a failed normalization can then be rerun safely. + Schema::table('recurring_invoices', function (Blueprint $table) { + $table->index(['status', 'next_invoice_at'], 'recurring_invoices_status_next_invoice_at_index'); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::table('recurring_invoices', function (Blueprint $table) { + $table->dropIndex('recurring_invoices_status_next_invoice_at_index'); + }); + } +}; diff --git a/database/seeders/RealisticDemoSeeder.php b/database/seeders/RealisticDemoSeeder.php index 1c0d8064..c5497c5d 100644 --- a/database/seeders/RealisticDemoSeeder.php +++ b/database/seeders/RealisticDemoSeeder.php @@ -26,6 +26,7 @@ use App\Models\Tax; use App\Models\TaxType; use App\Models\Unit; use App\Models\User; +use App\Services\Document\RecurringInvoiceScheduleService; use App\Services\Document\SerialNumberService; use Carbon\Carbon; use Illuminate\Database\Seeder; @@ -845,7 +846,9 @@ class RealisticDemoSeeder extends Seeder 'company_id' => $this->companyId, 'creator_id' => $this->user->id, 'status' => RecurringInvoice::ACTIVE, - 'next_invoice_at' => RecurringInvoice::getNextInvoiceDate($frequency, $startsAt), + 'next_invoice_at' => app(RecurringInvoiceScheduleService::class)->toStored( + app(RecurringInvoiceScheduleService::class)->firstFutureOccurrence($frequency, $startsAt, $this->companyId) + ), 'frequency' => $frequency, 'limit_by' => RecurringInvoice::NONE, 'currency_id' => $this->currencyId, diff --git a/routes/console.php b/routes/console.php index f7571995..7560fe4b 100644 --- a/routes/console.php +++ b/routes/console.php @@ -1,8 +1,5 @@ daily(); - $recurringInvoices = RecurringInvoice::where('status', 'ACTIVE')->get(); - foreach ($recurringInvoices as $recurringInvoice) { - $timeZone = CompanySetting::getSetting('time_zone', $recurringInvoice->company_id); - - Schedule::call(function () use ($recurringInvoice) { - app(RecurringInvoiceService::class)->generateInvoice($recurringInvoice); - })->cron($recurringInvoice->frequency)->timezone($timeZone); - } + Schedule::command('generate:recurring-invoices') + ->everyMinute() + ->withoutOverlapping(60); } diff --git a/tests/Feature/Admin/RecurringInvoiceTest.php b/tests/Feature/Admin/RecurringInvoiceTest.php index f4468250..14c3d8a7 100644 --- a/tests/Feature/Admin/RecurringInvoiceTest.php +++ b/tests/Feature/Admin/RecurringInvoiceTest.php @@ -1,7 +1,11 @@ assertOk(); }); + +test('frequency preview uses a form request', function () { + $this->assertActionUsesFormRequest( + RecurringInvoiceFrequencyController::class, + '__invoke', + RecurringInvoiceFrequencyRequest::class + ); +}); + +test('invalid frequency previews return validation errors', function () { + $queryString = http_build_query([ + 'frequency' => 'not a cron expression', + 'starts_at' => Carbon::now()->format('Y-m-d'), + ], '', '&'); + + getJson('api/v1/recurring-invoice-frequency?'.$queryString) + ->assertUnprocessable() + ->assertJsonValidationErrors('frequency'); +}); + +test('invalid recurring invoice cron expressions are rejected', function () { + $recurringInvoice = RecurringInvoice::factory()->raw(['frequency' => 'not a cron expression']); + $recurringInvoice['items'] = [InvoiceItem::factory()->raw()]; + + postJson('api/v1/recurring-invoices', $recurringInvoice) + ->assertUnprocessable() + ->assertJsonValidationErrors('frequency'); +}); + +test('creating a recurring invoice with a past start uses the next future occurrence', function () { + Carbon::setTestNow(Carbon::parse('2026-08-02 12:00:00', 'UTC')); + + try { + CompanySetting::setSettings(['time_zone' => 'UTC'], User::findOrFail(1)->companies()->firstOrFail()->id); + + $recurringInvoice = RecurringInvoice::factory()->raw([ + 'starts_at' => '2026-07-01 00:00:00', + 'frequency' => '0 0 * * *', + 'status' => RecurringInvoice::ACTIVE, + ]); + $recurringInvoice['items'] = [InvoiceItem::factory()->raw()]; + + $response = postJson('api/v1/recurring-invoices', $recurringInvoice) + ->assertCreated(); + + $this->assertDatabaseHas('recurring_invoices', [ + 'id' => $response->json('data.id'), + 'next_invoice_at' => '2026-08-03 00:00:00', + ]); + } finally { + Carbon::setTestNow(); + } +}); + +test('reactivating a past recurring invoice starts from the next future occurrence', function () { + Carbon::setTestNow(Carbon::parse('2026-08-02 12:00:00', 'UTC')); + + try { + $companyId = User::findOrFail(1)->companies()->firstOrFail()->id; + CompanySetting::setSettings(['time_zone' => 'UTC'], $companyId); + $recurringInvoice = RecurringInvoice::factory()->create([ + 'status' => RecurringInvoice::ON_HOLD, + 'starts_at' => '2026-07-01 00:00:00', + 'frequency' => '0 0 * * *', + 'next_invoice_at' => '2026-07-02 00:00:00', + ]); + $payload = RecurringInvoice::factory()->raw([ + 'status' => RecurringInvoice::ACTIVE, + 'starts_at' => '2026-07-01 00:00:00', + 'frequency' => '0 0 * * *', + ]); + $payload['items'] = [InvoiceItem::factory()->raw()]; + + putJson("api/v1/recurring-invoices/{$recurringInvoice->id}", $payload) + ->assertOk(); + + $this->assertDatabaseHas('recurring_invoices', [ + 'id' => $recurringInvoice->id, + 'status' => RecurringInvoice::ACTIVE, + 'next_invoice_at' => '2026-08-03 00:00:00', + ]); + } finally { + Carbon::setTestNow(); + } +}); + +test('the dispatcher ignores non-active templates and does not duplicate an occurrence', function () { + Carbon::setTestNow(Carbon::parse('2026-08-02 12:00:00', 'UTC')); + + try { + $active = RecurringInvoice::factory()->create([ + 'status' => RecurringInvoice::ACTIVE, + 'frequency' => '* * * * *', + 'limit_by' => RecurringInvoice::NONE, + 'next_invoice_at' => Carbon::now()->format('Y-m-d H:i:s'), + ]); + $onHold = RecurringInvoice::factory()->create([ + 'status' => RecurringInvoice::ON_HOLD, + 'frequency' => '* * * * *', + 'limit_by' => RecurringInvoice::NONE, + 'next_invoice_at' => Carbon::now()->format('Y-m-d H:i:s'), + ]); + + Artisan::call('generate:recurring-invoices'); + Artisan::call('generate:recurring-invoices'); + + expect(Invoice::where('recurring_invoice_id', $active->id)->count())->toBe(1) + ->and(Invoice::where('recurring_invoice_id', $onHold->id)->count())->toBe(0); + } finally { + Carbon::setTestNow(); + } +}); diff --git a/tests/Feature/RecurringInvoiceSchedulerTest.php b/tests/Feature/RecurringInvoiceSchedulerTest.php new file mode 100644 index 00000000..5bac5f0e --- /dev/null +++ b/tests/Feature/RecurringInvoiceSchedulerTest.php @@ -0,0 +1,296 @@ +set('app.timezone', 'UTC'); + + Artisan::call('db:seed', ['--class' => 'DatabaseSeeder', '--force' => true]); + Artisan::call('db:seed', ['--class' => 'DemoSeeder', '--force' => true]); + + $this->schedulerCompany = User::findOrFail(1)->companies()->firstOrFail(); + + CompanySetting::setSettings([ + 'time_zone' => 'UTC', + 'invoice_due_date_days' => 7, + 'invoice_email_attachment' => 'NO', + 'invoice_mail_body' => 'Invoice {INVOICE_NUMBER}', + 'invoice_number_format' => '{{DATE_FORMAT:Ymd}}{{DELIMITER:-}}{{SEQUENCE:3}}', + ], $this->schedulerCompany->id); +}); + +afterEach(function () { + Carbon::setTestNow(); +}); + +function createSchedulerTemplate(int $companyId, array $attributes = []): RecurringInvoice +{ + $customerId = $attributes['customer_id'] ?? Customer::factory()->create([ + 'company_id' => $companyId, + ])->id; + + return RecurringInvoice::factory()->create(array_merge([ + 'company_id' => $companyId, + 'creator_id' => User::findOrFail(1)->id, + 'customer_id' => $customerId, + 'starts_at' => '2026-01-01 00:00:00', + 'status' => RecurringInvoice::ACTIVE, + 'frequency' => '0 0 * * *', + 'next_invoice_at' => '2026-08-01 00:00:00', + 'limit_by' => RecurringInvoice::NONE, + 'limit_count' => null, + 'limit_date' => null, + 'send_automatically' => false, + 'exchange_rate' => 1, + ], $attributes)); +} + +test('the scheduler registers one recurring invoice dispatcher', function () { + $routes = file_get_contents(base_path('routes/console.php')); + + expect($routes) + ->toContain("Schedule::command('generate:recurring-invoices')") + ->toContain('->everyMinute()') + ->toContain('->withoutOverlapping(60)') + ->not->toContain('RecurringInvoice::where') + ->not->toContain('Schedule::call'); +}); + +test('recurrence calculations use the company timezone across daylight saving time', function () { + $company = Company::create(['name' => 'Timezone test']); + CompanySetting::setSettings(['time_zone' => 'America/New_York'], $company->id); + + $schedule = app(RecurringInvoiceScheduleService::class); + $next = $schedule->nextOccurrence( + '0 9 * * *', + Carbon::parse('2026-03-07 09:00:00', 'America/New_York'), + 'America/New_York' + ); + + expect($next->format('Y-m-d H:i:s P'))->toBe('2026-03-08 09:00:00 -04:00') + ->and($schedule->toStored($next))->toBe('2026-03-08 13:00:00'); +}); + +test('stored occurrences convert between different application and company timezones', function () { + config()->set('app.timezone', 'Europe/Skopje'); + CompanySetting::setSettings(['time_zone' => 'America/New_York'], $this->schedulerCompany->id); + $schedule = app(RecurringInvoiceScheduleService::class); + $occurrence = Carbon::parse('2026-08-02 09:00:00', 'America/New_York'); + + $stored = $schedule->toStored($occurrence); + + expect($stored)->toBe('2026-08-02 15:00:00') + ->and($schedule->fromStored($stored, $this->schedulerCompany->id)->format('Y-m-d H:i:s P')) + ->toBe('2026-08-02 09:00:00 -04:00'); +}); + +test('recurring invoice resources expose the next occurrence in company time', function () { + CompanySetting::setSettings([ + 'time_zone' => 'America/New_York', + 'carbon_date_format' => 'Y-m-d', + ], $this->schedulerCompany->id); + $recurringInvoice = createSchedulerTemplate($this->schedulerCompany->id, [ + 'next_invoice_at' => '2026-03-08 13:00:00', + ]); + + $resource = (new RecurringInvoiceResource($recurringInvoice))->resolve(); + + expect($resource['next_invoice_at'])->toBe('2026-03-08 09:00:00') + ->and($resource['formatted_next_invoice_at'])->toBe('2026-03-08'); +}); + +test('a future start matching the cron is retained as the first occurrence', function () { + $company = Company::create(['name' => 'Future start test']); + CompanySetting::setSettings(['time_zone' => 'UTC'], $company->id); + + $occurrence = app(RecurringInvoiceScheduleService::class)->firstFutureOccurrence( + '0 9 * * *', + '2030-01-01 09:00:00', + $company->id, + Carbon::parse('2029-12-31 09:00:00', 'UTC') + ); + + expect($occurrence->format('Y-m-d H:i:s'))->toBe('2030-01-01 09:00:00'); +}); + +test('a future start after the cron minute advances to the next occurrence', function () { + $occurrence = app(RecurringInvoiceScheduleService::class)->firstFutureOccurrence( + '0 9 * * *', + '2030-01-01 09:00:30', + $this->schedulerCompany->id, + Carbon::parse('2029-12-31 09:00:00', 'UTC') + ); + + expect($occurrence->format('Y-m-d H:i:s'))->toBe('2030-01-02 09:00:00'); +}); + +test('past starts are scheduled at the next future occurrence', function () { + $next = app(RecurringInvoiceScheduleService::class)->firstFutureOccurrence( + '0 0 * * *', + '2026-07-01 00:00:00', + $this->schedulerCompany->id, + Carbon::parse('2026-08-02 12:00:00', 'UTC') + ); + + expect($next->format('Y-m-d H:i:s'))->toBe('2026-08-03 00:00:00'); +}); + +test('catch-up invoices use each scheduled date for document dates and number placeholders', function () { + Carbon::setTestNow(Carbon::parse('2026-08-03 12:00:00', 'UTC')); + $recurringInvoice = createSchedulerTemplate($this->schedulerCompany->id); + + Artisan::call('generate:recurring-invoices'); + + $invoices = Invoice::query() + ->where('recurring_invoice_id', $recurringInvoice->id) + ->orderBy('invoice_date') + ->get(); + + expect($invoices)->toHaveCount(3) + ->and($invoices->map(fn (Invoice $invoice) => Carbon::parse($invoice->invoice_date)->toDateString())->all()) + ->toBe(['2026-08-01', '2026-08-02', '2026-08-03']) + ->and($invoices->map(fn (Invoice $invoice) => Carbon::parse($invoice->due_date)->toDateString())->all()) + ->toBe(['2026-08-08', '2026-08-09', '2026-08-10']) + ->and($invoices->pluck('invoice_number')->all()) + ->toBe(['20260801-001', '20260802-002', '20260803-003']) + ->and($recurringInvoice->fresh()->next_invoice_at) + ->toBe('2026-08-04 00:00:00'); +}); + +test('catch-up work is fair and limited to ten invoices per template', function () { + Carbon::setTestNow(Carbon::parse('2026-08-01 00:30:00', 'UTC')); + $first = createSchedulerTemplate($this->schedulerCompany->id, [ + 'frequency' => '* * * * *', + 'next_invoice_at' => '2026-08-01 00:00:00', + ]); + $second = createSchedulerTemplate($this->schedulerCompany->id, [ + 'frequency' => '* * * * *', + 'next_invoice_at' => '2026-08-01 00:00:00', + ]); + + Artisan::call('generate:recurring-invoices'); + + expect($first->invoices()->count())->toBe(10) + ->and($second->invoices()->count())->toBe(10) + ->and($first->fresh()->next_invoice_at)->toBe('2026-08-01 00:10:00') + ->and($second->fresh()->next_invoice_at)->toBe('2026-08-01 00:10:00'); +}); + +test('a dispatcher invocation generates at most one hundred invoices', function () { + Carbon::setTestNow(Carbon::parse('2026-08-02 12:00:00', 'UTC')); + $customer = Customer::factory()->create(['company_id' => $this->schedulerCompany->id]); + + $recurringInvoices = RecurringInvoice::factory()->count(101)->create([ + 'company_id' => $this->schedulerCompany->id, + 'creator_id' => User::findOrFail(1)->id, + 'customer_id' => $customer->id, + 'starts_at' => '2026-01-01 12:00:00', + 'status' => RecurringInvoice::ACTIVE, + 'frequency' => '0 12 * * *', + 'next_invoice_at' => '2026-08-02 12:00:00', + 'limit_by' => RecurringInvoice::NONE, + 'limit_count' => null, + 'limit_date' => null, + 'send_automatically' => false, + 'exchange_rate' => 1, + ]); + + Artisan::call('generate:recurring-invoices'); + + expect(Invoice::whereIn('recurring_invoice_id', $recurringInvoices->modelKeys())->count())->toBe(100) + ->and($recurringInvoices->filter(fn (RecurringInvoice $template) => $template->invoices()->exists())) + ->toHaveCount(100); +}); + +test('count and date limits complete templates after the final scheduled occurrence', function () { + Carbon::setTestNow(Carbon::parse('2026-08-05 12:00:00', 'UTC')); + $countLimited = createSchedulerTemplate($this->schedulerCompany->id, [ + 'limit_by' => RecurringInvoice::COUNT, + 'limit_count' => 2, + ]); + $dateLimited = createSchedulerTemplate($this->schedulerCompany->id, [ + 'limit_by' => RecurringInvoice::DATE, + 'limit_date' => '2026-08-02', + ]); + + Artisan::call('generate:recurring-invoices'); + + expect($countLimited->invoices()->count())->toBe(2) + ->and($countLimited->fresh()->status)->toBe(RecurringInvoice::COMPLETED) + ->and($dateLimited->invoices()->count())->toBe(2) + ->and($dateLimited->fresh()->status)->toBe(RecurringInvoice::COMPLETED); +}); + +test('automatic sending applies to every generated catch-up invoice', function () { + Carbon::setTestNow(Carbon::parse('2026-08-02 12:00:00', 'UTC')); + Mail::fake(); + $recurringInvoice = createSchedulerTemplate($this->schedulerCompany->id, [ + 'next_invoice_at' => '2026-08-01 00:00:00', + 'send_automatically' => true, + ]); + + Artisan::call('generate:recurring-invoices'); + + Mail::assertSent(SendInvoiceMail::class, 2); + expect($recurringInvoice->invoices()->where('status', Invoice::STATUS_SENT)->count())->toBe(2); +}); + +test('mail failures do not regenerate an already committed invoice', function () { + Carbon::setTestNow(Carbon::parse('2026-08-02 12:00:00', 'UTC')); + Log::spy(); + $invoiceService = Mockery::mock(InvoiceService::class); + $invoiceService->shouldReceive('send')->once()->andThrow(new RuntimeException('Mail transport failed.')); + $this->app->instance(InvoiceService::class, $invoiceService); + $recurringInvoice = createSchedulerTemplate($this->schedulerCompany->id, [ + 'frequency' => '0 12 * * *', + 'next_invoice_at' => '2026-08-02 12:00:00', + 'send_automatically' => true, + ]); + + Artisan::call('generate:recurring-invoices'); + Artisan::call('generate:recurring-invoices'); + + expect($recurringInvoice->invoices()->count())->toBe(1) + ->and($recurringInvoice->fresh()->next_invoice_at)->toBe('2026-08-03 12:00:00'); + + Log::shouldHaveReceived('error')->once(); +}); + +test('one broken template is attempted once and does not block healthy templates', function () { + Carbon::setTestNow(Carbon::parse('2026-08-02 12:00:00', 'UTC')); + Log::spy(); + $broken = createSchedulerTemplate($this->schedulerCompany->id, [ + 'frequency' => 'not a cron expression', + 'next_invoice_at' => '2026-08-02 12:00:00', + ]); + $healthy = createSchedulerTemplate($this->schedulerCompany->id, [ + 'frequency' => '0 12 * * *', + 'next_invoice_at' => '2026-08-02 12:00:00', + ]); + + Artisan::call('generate:recurring-invoices'); + + expect($broken->invoices()->count())->toBe(0) + ->and($broken->fresh()->next_invoice_at)->toBe('2026-08-02 12:00:00') + ->and($healthy->invoices()->count())->toBe(1); + + Log::shouldHaveReceived('error')->once(); + + $broken->update(['frequency' => '0 12 * * *']); + Artisan::call('generate:recurring-invoices'); + + expect($broken->invoices()->count())->toBe(1); +});