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

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