fix(recurring-invoices): scale scheduled generation

This commit is contained in:
Darko Gjorgjijoski
2026-08-02 23:01:22 +02:00
parent 8ae82ae91e
commit dbc6ca7ad6
15 changed files with 845 additions and 82 deletions

View File

@@ -0,0 +1,25 @@
<?php
namespace App\Console\Commands;
use App\Services\Document\RecurringInvoiceService;
use Illuminate\Console\Command;
class GenerateRecurringInvoices extends Command
{
protected $signature = 'generate:recurring-invoices';
protected $description = 'Generate due recurring invoices.';
/**
* Execute the console command.
*/
public function handle(RecurringInvoiceService $recurringInvoiceService): int
{
$generated = $recurringInvoiceService->generateDueInvoices();
$this->info("Generated {$generated} recurring invoice(s).");
return self::SUCCESS;
}
}

View File

@@ -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,

View File

@@ -0,0 +1,31 @@
<?php
namespace App\Http\Requests;
use App\Rules\ValidCronExpression;
use Illuminate\Contracts\Validation\ValidationRule;
use Illuminate\Foundation\Http\FormRequest;
class RecurringInvoiceFrequencyRequest extends FormRequest
{
/**
* Determine if the user is authorized to make this request.
*/
public function authorize(): bool
{
return true;
}
/**
* Get the validation rules that apply to the request.
*
* @return array<string, ValidationRule|array<mixed>|string>
*/
public function rules(): array
{
return [
'frequency' => ['required', 'string', new ValidCronExpression],
'starts_at' => ['required', 'date'],
];
}
}

View File

@@ -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';

View File

@@ -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,

View File

@@ -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();
}
}

View File

@@ -0,0 +1,23 @@
<?php
namespace App\Rules;
use Closure;
use Cron\CronExpression;
use Illuminate\Contracts\Validation\ValidationRule;
use Illuminate\Translation\PotentiallyTranslatedString;
class ValidCronExpression implements ValidationRule
{
/**
* Run the validation rule.
*
* @param Closure(string, ?string=): PotentiallyTranslatedString $fail
*/
public function validate(string $attribute, mixed $value, Closure $fail): void
{
if (! is_string($value) || ! CronExpression::isValidExpression($value)) {
$fail('The frequency must be a valid cron expression.');
}
}
}

View File

@@ -0,0 +1,78 @@
<?php
namespace App\Services\Document;
use App\Models\CompanySetting;
use Carbon\Carbon;
use Cron\CronExpression;
use DateTimeZone;
class RecurringInvoiceScheduleService
{
/** @var array<int, string> */
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');
}
}

View File

@@ -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,
]);
}
}

View File

@@ -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':

View File

@@ -0,0 +1,92 @@
<?php
use Cron\CronExpression;
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Carbon;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Log;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
$applicationTimezone = config('app.timezone', 'UTC');
$cutover = Carbon::now($applicationTimezone);
DB::table('recurring_invoices')
->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');
});
}
};

View File

@@ -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,

View File

@@ -1,8 +1,5 @@
<?php
use App\Models\CompanySetting;
use App\Models\RecurringInvoice;
use App\Services\Document\RecurringInvoiceService;
use App\Support\Setup\InstallUtils;
use Illuminate\Support\Facades\Schedule;
@@ -21,12 +18,7 @@ if (InstallUtils::isDbCreated()) {
Schedule::command('check:estimates:status')
->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);
}

View File

@@ -1,7 +1,11 @@
<?php
use App\Http\Controllers\Company\RecurringInvoice\RecurringInvoiceController;
use App\Http\Controllers\Company\RecurringInvoice\RecurringInvoiceFrequencyController;
use App\Http\Requests\RecurringInvoiceFrequencyRequest;
use App\Http\Requests\RecurringInvoiceRequest;
use App\Models\CompanySetting;
use App\Models\Invoice;
use App\Models\InvoiceItem;
use App\Models\RecurringInvoice;
use App\Models\User;
@@ -127,3 +131,115 @@ test('calculate frequency for recurring invoice', function () {
getJson('api/v1/recurring-invoice-frequency?'.$queryString)
->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();
}
});

View File

@@ -0,0 +1,296 @@
<?php
use App\Http\Resources\RecurringInvoiceResource;
use App\Mail\SendInvoiceMail;
use App\Models\Company;
use App\Models\CompanySetting;
use App\Models\Customer;
use App\Models\Invoice;
use App\Models\RecurringInvoice;
use App\Models\User;
use App\Services\Document\InvoiceService;
use App\Services\Document\RecurringInvoiceScheduleService;
use Carbon\Carbon;
use Illuminate\Support\Facades\Artisan;
use Illuminate\Support\Facades\Log;
use Illuminate\Support\Facades\Mail;
beforeEach(function () {
config()->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);
});