mirror of
https://github.com/InvoiceShelf/InvoiceShelf.git
synced 2026-08-03 22:52:13 +00:00
fix(recurring-invoices): scale scheduled generation
This commit is contained in:
25
app/Console/Commands/GenerateRecurringInvoices.php
Normal file
25
app/Console/Commands/GenerateRecurringInvoices.php
Normal 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;
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
31
app/Http/Requests/RecurringInvoiceFrequencyRequest.php
Normal file
31
app/Http/Requests/RecurringInvoiceFrequencyRequest.php
Normal 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'],
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -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';
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
23
app/Rules/ValidCronExpression.php
Normal file
23
app/Rules/ValidCronExpression.php
Normal 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.');
|
||||
}
|
||||
}
|
||||
}
|
||||
78
app/Services/Document/RecurringInvoiceScheduleService.php
Normal file
78
app/Services/Document/RecurringInvoiceScheduleService.php
Normal 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');
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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':
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
}
|
||||
};
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
});
|
||||
|
||||
296
tests/Feature/RecurringInvoiceSchedulerTest.php
Normal file
296
tests/Feature/RecurringInvoiceSchedulerTest.php
Normal 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);
|
||||
});
|
||||
Reference in New Issue
Block a user