mirror of
https://github.com/InvoiceShelf/InvoiceShelf.git
synced 2026-08-03 22:52:13 +00:00
297 lines
12 KiB
PHP
297 lines
12 KiB
PHP
<?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);
|
|
});
|