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