mirror of
https://github.com/InvoiceShelf/InvoiceShelf.git
synced 2026-08-04 07:02:13 +00:00
fix(recurring-invoices): scale scheduled generation
This commit is contained in:
@@ -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,
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user