From 9fc3a0930beb96ee08bbcdae9716fba2c5c4af89 Mon Sep 17 00:00:00 2001 From: KhawarMehfooz Date: Fri, 31 Jul 2026 16:20:37 +0500 Subject: [PATCH 1/3] feat: Send invoice reminders for overdue invoices. Closes #480 --- ...d_type_and_sent_at_to_mail_queue_table.php | 22 ++ .../Database/Seeders/EmailTemplatesSeeder.php | 6 + Modules/Core/Models/MailQueue.php | 4 + .../CompanyDefaultsBootstrapService.php | 16 + .../Company/Actions/SendReminderAction.php | 74 ++++ .../Resources/Invoices/Pages/EditInvoice.php | 5 + .../Invoices/Schemas/InvoiceForm.php | 14 + .../Invoices/Tables/InvoicesTable.php | 4 + .../Invoices/Mail/InvoiceReminderMailable.php | 49 +++ Modules/Invoices/Services/InvoiceService.php | 99 ++++++ .../Tests/Feature/SendReminderActionTest.php | 316 ++++++++++++++++++ resources/lang/en/ip.php | 6 + 12 files changed, 615 insertions(+) create mode 100644 Modules/Core/Database/Migrations/2026_07_31_000001_add_type_and_sent_at_to_mail_queue_table.php create mode 100644 Modules/Invoices/Filament/Company/Actions/SendReminderAction.php create mode 100644 Modules/Invoices/Mail/InvoiceReminderMailable.php create mode 100644 Modules/Invoices/Tests/Feature/SendReminderActionTest.php diff --git a/Modules/Core/Database/Migrations/2026_07_31_000001_add_type_and_sent_at_to_mail_queue_table.php b/Modules/Core/Database/Migrations/2026_07_31_000001_add_type_and_sent_at_to_mail_queue_table.php new file mode 100644 index 000000000..c5862d322 --- /dev/null +++ b/Modules/Core/Database/Migrations/2026_07_31_000001_add_type_and_sent_at_to_mail_queue_table.php @@ -0,0 +1,22 @@ +string('type')->nullable()->after('mailable_type'); + $table->timestamp('sent_at')->nullable()->after('is_sent'); + }); + } + + public function down(): void + { + Schema::table('mail_queue', function (Blueprint $table): void { + $table->dropColumn(['type', 'sent_at']); + }); + } +}; diff --git a/Modules/Core/Database/Seeders/EmailTemplatesSeeder.php b/Modules/Core/Database/Seeders/EmailTemplatesSeeder.php index 64285566a..a208fb394 100644 --- a/Modules/Core/Database/Seeders/EmailTemplatesSeeder.php +++ b/Modules/Core/Database/Seeders/EmailTemplatesSeeder.php @@ -28,6 +28,12 @@ public function buildOne(?int $companyId = null): void 'body' => "Dear {{ customer.name }},\n\nA new quote #{{ quote.number }} has been prepared for you.\n\nAmount: {{ quote.total_formatted }}\nValid Until: {{ quote.valid_until_formatted }}\n\nYou can view the quote by clicking the link below:\n{{ quote.public_url }}\n\nPlease let us know if you have any questions.\n\nBest regards,\n{{ company.name }}", 'company_id' => $companyId, ], + [ + 'title' => 'invoice_reminder', + 'subject' => 'Payment reminder — Invoice #{{ invoice.number }}', + 'body' => "Dear {{ customer.name }},\n\nThis is a friendly reminder that invoice #{{ invoice.number }} for {{ invoice.total_formatted }} was due on {{ invoice.due_date_formatted }} and remains unpaid.\n\nPlease arrange payment at your earliest convenience. The invoice is attached for your reference.\n\nThank you,\n{{ company.name }}", + 'company_id' => $companyId, + ], [ 'title' => 'user_invitation', 'subject' => 'You have been invited to {{ company.name }}', diff --git a/Modules/Core/Models/MailQueue.php b/Modules/Core/Models/MailQueue.php index 0ab7c41bc..056db7f4b 100644 --- a/Modules/Core/Models/MailQueue.php +++ b/Modules/Core/Models/MailQueue.php @@ -4,11 +4,13 @@ use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Relations\MorphTo; +use Illuminate\Support\Carbon; /** * @property int $id * @property int $mailable_id * @property string $mailable_type + * @property string|null $type * @property string $from * @property string $to * @property string $cc @@ -18,6 +20,7 @@ * @property bool $attach_pdf * @property bool $is_sent * @property string|null $error + * @property Carbon|null $sent_at */ class MailQueue extends Model { @@ -28,6 +31,7 @@ class MailQueue extends Model protected $casts = [ 'attach_pdf' => 'bool', 'is_sent' => 'bool', + 'sent_at' => 'datetime', ]; protected $guarded = []; diff --git a/Modules/Core/Services/CompanyDefaultsBootstrapService.php b/Modules/Core/Services/CompanyDefaultsBootstrapService.php index 9f7dea8d9..155957001 100644 --- a/Modules/Core/Services/CompanyDefaultsBootstrapService.php +++ b/Modules/Core/Services/CompanyDefaultsBootstrapService.php @@ -59,6 +59,22 @@ public static function bootstrap(int $companyId): void ] ); + // Create default reminder email template + EmailTemplate::query()->firstOrCreate( + [ + 'company_id' => $company->id, + 'title' => 'invoice_reminder', + ], + [ + 'subject' => 'Payment reminder — Invoice #{{ invoice.number }}', + 'body' => 'Invoice #{{ invoice.number }} is overdue. Please arrange payment at your earliest convenience.', + 'from_name' => $company->name, + 'from_email' => 'billing@' . mb_strtolower(preg_replace('/[^A-Za-z0-9]/', '', $company->name)) . '.com', + 'cc' => null, + 'bcc' => null, + ] + ); + // Create default tax rate TaxRate::query()->firstOrCreate( [ diff --git a/Modules/Invoices/Filament/Company/Actions/SendReminderAction.php b/Modules/Invoices/Filament/Company/Actions/SendReminderAction.php new file mode 100644 index 000000000..e0d33f055 --- /dev/null +++ b/Modules/Invoices/Filament/Company/Actions/SendReminderAction.php @@ -0,0 +1,74 @@ +visible() + * closure alongside any permission check. + */ + public static function isOverdue(Invoice $record): bool + { + return in_array($record->invoice_status, [InvoiceStatus::SENT, InvoiceStatus::OVERDUE], true) + && $record->invoice_due_at !== null + && $record->invoice_due_at->isPast(); + } + + public static function make(): Action + { + return Action::make('send_reminder') + ->label(trans('ip.send_reminder')) + ->icon(Heroicon::OutlinedBellAlert) + ->disabled(fn (Invoice $record): bool => blank(app(InvoiceService::class)->resolveReminderDefaults($record)['recipient'])) + ->tooltip(fn (Invoice $record): ?string => blank(app(InvoiceService::class)->resolveReminderDefaults($record)['recipient']) + ? trans('ip.customer_has_no_email') + : null) + ->schema(function (Invoice $record) { + $defaults = app(InvoiceService::class)->resolveReminderDefaults($record); + + return [ + TextInput::make('recipient') + ->label(trans('ip.recipient')) + ->email() + ->required() + ->default($defaults['recipient']), + TextInput::make('subject') + ->label(trans('ip.subject')) + ->required() + ->default($defaults['subject']), + Textarea::make('body') + ->label(trans('ip.body')) + ->required() + ->rows(10) + ->default($defaults['body']), + ]; + }) + ->modalHeading(trans('ip.send_reminder')) + ->modalSubmitActionLabel(trans('ip.send_reminder')) + ->action(function (Invoice $record, array $data): void { + app(InvoiceService::class)->sendReminder( + $record, + $data['recipient'], + $data['subject'], + $data['body'], + ); + + Notification::make() + ->title(trans('ip.reminder_sent')) + ->body(trans('ip.reminder_sent_successfully')) + ->success() + ->send(); + }); + } +} diff --git a/Modules/Invoices/Filament/Company/Resources/Invoices/Pages/EditInvoice.php b/Modules/Invoices/Filament/Company/Resources/Invoices/Pages/EditInvoice.php index 103043cb0..d3704de67 100644 --- a/Modules/Invoices/Filament/Company/Resources/Invoices/Pages/EditInvoice.php +++ b/Modules/Invoices/Filament/Company/Resources/Invoices/Pages/EditInvoice.php @@ -13,6 +13,7 @@ use Modules\Core\Enums\Permission; use Modules\Invoices\Enums\InvoiceStatus; use Modules\Invoices\Filament\Company\Actions\EmailInvoiceAction; +use Modules\Invoices\Filament\Company\Actions\SendReminderAction; use Modules\Invoices\Filament\Company\Resources\Invoices\InvoiceResource; use Modules\Invoices\Services\InvoiceService; @@ -101,6 +102,10 @@ protected function getHeaderActions(): array EmailInvoiceAction::make() ->visible(fn () => auth()->user()?->can(Permission::EMAIL_INVOICES->value)), + SendReminderAction::make() + ->visible(fn () => auth()->user()?->can(Permission::EMAIL_INVOICES->value) + && SendReminderAction::isOverdue($this->getRecord())), + Action::make('create_recurring') ->label(trans('ip.create_recurring')) ->icon(Heroicon::OutlinedArrowPath) diff --git a/Modules/Invoices/Filament/Company/Resources/Invoices/Schemas/InvoiceForm.php b/Modules/Invoices/Filament/Company/Resources/Invoices/Schemas/InvoiceForm.php index d161568cd..9f76ae5d8 100644 --- a/Modules/Invoices/Filament/Company/Resources/Invoices/Schemas/InvoiceForm.php +++ b/Modules/Invoices/Filament/Company/Resources/Invoices/Schemas/InvoiceForm.php @@ -19,7 +19,10 @@ use Modules\Core\Enums\NumberingType; use Modules\Core\Filament\Company\Actions\InsertNoteTemplateAction; use Modules\Core\Models\Setting; +use Modules\Core\Support\DateHelpers; use Modules\Invoices\Enums\InvoiceStatus; +use Modules\Invoices\Models\Invoice; +use Modules\Invoices\Services\InvoiceService; use Modules\Invoices\Support\InvoiceCalculator; use Modules\Invoices\Support\InvoiceNumberGenerator; use Modules\Products\Models\Product; @@ -115,6 +118,17 @@ public static function configure(Schema $schema): Schema ->label(trans('ip.invoice_due_at')) ->required(), + Placeholder::make('last_reminder_sent') + ->label(trans('ip.last_reminder_sent')) + ->visible(fn (string $operation): bool => $operation === 'edit') + ->content(function (?Invoice $record) { + $lastSentAt = $record ? app(InvoiceService::class)->lastReminderSentAt($record) : null; + + return $lastSentAt + ? DateHelpers::formatDate($lastSentAt) + : trans('ip.reminder_never_sent'); + }), + Select::make('numbering_id') ->label(trans('ip.numbering')) ->relationship('numbering', 'name', fn ($query) => $query->where('type', NumberingType::INVOICE->value)) diff --git a/Modules/Invoices/Filament/Company/Resources/Invoices/Tables/InvoicesTable.php b/Modules/Invoices/Filament/Company/Resources/Invoices/Tables/InvoicesTable.php index f4e16f7c2..2d065714f 100644 --- a/Modules/Invoices/Filament/Company/Resources/Invoices/Tables/InvoicesTable.php +++ b/Modules/Invoices/Filament/Company/Resources/Invoices/Tables/InvoicesTable.php @@ -23,6 +23,7 @@ use Modules\Core\Support\DateHelpers; use Modules\Invoices\Enums\InvoiceStatus; use Modules\Invoices\Filament\Company\Actions\EmailInvoiceAction; +use Modules\Invoices\Filament\Company\Actions\SendReminderAction; use Modules\Invoices\Models\Invoice; use Modules\Invoices\Services\InvoiceCopyService; use Modules\Invoices\Services\InvoiceService; @@ -201,6 +202,9 @@ public static function configure(Table $table): Table ->tooltip(fn (Invoice $record): ?string => blank(app(InvoiceService::class)->resolveEmailDefaults($record)['recipient']) ? trans('ip.customer_has_no_email') : null), + SendReminderAction::make() + ->visible(fn (Invoice $record): bool => auth()->user()?->can(Permission::EMAIL_INVOICES->value) + && SendReminderAction::isOverdue($record)), DeleteAction::make('delete') ->visible(fn (Invoice $record) => auth()->user()?->can(Permission::DELETE_INVOICES->value) && $record->invoice_status !== InvoiceStatus::PAID) diff --git a/Modules/Invoices/Mail/InvoiceReminderMailable.php b/Modules/Invoices/Mail/InvoiceReminderMailable.php new file mode 100644 index 000000000..4701b9333 --- /dev/null +++ b/Modules/Invoices/Mail/InvoiceReminderMailable.php @@ -0,0 +1,49 @@ +emailSubject, + ); + } + + public function content(): Content + { + return new Content( + htmlString: nl2br(e($this->bodyText)), + ); + } + + public function attachments(): array + { + $filename = ($this->invoice->invoice_number ?: 'invoice-' . $this->invoice->id) . '.pdf'; + + return [ + Attachment::fromData(fn () => $this->pdfBinary, $filename) + ->withMime('application/pdf'), + ]; + } +} diff --git a/Modules/Invoices/Services/InvoiceService.php b/Modules/Invoices/Services/InvoiceService.php index 73350b6dc..347392294 100644 --- a/Modules/Invoices/Services/InvoiceService.php +++ b/Modules/Invoices/Services/InvoiceService.php @@ -15,6 +15,7 @@ use Modules\Core\Support\PDF\PDFFactory; use Modules\Invoices\Enums\InvoiceStatus; use Modules\Invoices\Mail\InvoiceMailable; +use Modules\Invoices\Mail\InvoiceReminderMailable; use Modules\Invoices\Models\Invoice; use Symfony\Component\HttpFoundation\StreamedResponse; use Throwable; @@ -26,6 +27,16 @@ class InvoiceService extends BaseService */ public const INVOICE_EMAIL_TEMPLATE_TITLE = 'invoice_sent'; + /** + * Title of the EmailTemplate used as the company's overdue-invoice reminder template. + */ + public const INVOICE_REMINDER_EMAIL_TEMPLATE_TITLE = 'invoice_reminder'; + + /** + * MailQueue#type value used to identify reminder emails. + */ + public const REMINDER_MAIL_TYPE = 'reminder'; + public function model(): string { return Invoice::class; @@ -261,6 +272,94 @@ public function sendInvoiceEmail(Invoice $invoice, string $recipient, string $su ->queue(new InvoiceMailable($invoice, $subject, $body)); } + /** + * Resolve the recipient/subject/body defaults for the "Send Reminder" + * modal, rendering the company's reminder email template against this invoice. + */ + public function resolveReminderDefaults(Invoice $invoice): array + { + $invoice->loadMissing(['customer', 'company']); + + $template = EmailTemplate::forCompany($invoice->company_id) + ->where('title', self::INVOICE_REMINDER_EMAIL_TEMPLATE_TITLE) + ->first(); + + $placeholders = [ + 'invoice.number' => $invoice->invoice_number, + 'invoice.total_formatted' => number_format((float) $invoice->invoice_total, 2), + 'invoice.due_date_formatted' => DateHelpers::formatDate($invoice->invoice_due_at), + 'customer.name' => $invoice->customer?->company_name, + 'company.name' => $invoice->company?->name, + ]; + + $defaultSubject = trans('ip.reminder_default_subject', ['number' => $invoice->invoice_number]); + + return [ + 'recipient' => $this->resolveInvoiceRecipientEmail($invoice), + 'subject' => $template?->subject + ? EmailTemplatePreview::render($template->subject, $placeholders) + : $defaultSubject, + 'body' => $template?->body + ? EmailTemplatePreview::render($template->body, $placeholders) + : '', + ]; + } + + /** + * Queue a payment reminder for the given (possibly user-edited) + * recipient/subject/body, attach the invoice PDF, and log a MailQueue + * entry of type "reminder" so the invoice's reminder history is auditable. + * Sending multiple reminders creates a separate MailQueue row each time. + */ + public function sendReminder(Invoice $invoice, ?string $recipient = null, ?string $subject = null, ?string $body = null): void + { + $defaults = $this->resolveReminderDefaults($invoice); + + $recipient ??= $defaults['recipient']; + $subject ??= $defaults['subject']; + $body ??= $defaults['body']; + + if (blank($recipient)) { + throw new InvalidArgumentException(trans('ip.customer_has_no_email')); + } + + $template = EmailTemplate::forCompany($invoice->company_id) + ->where('title', self::INVOICE_REMINDER_EMAIL_TEMPLATE_TITLE) + ->first(); + $pdfBinary = PDFFactory::create()->getOutput($this->renderHtml($invoice)); + + Mail::to($recipient)->queue(new InvoiceReminderMailable($invoice, $subject, $body, $pdfBinary)); + + $invoice->mailQueue()->create([ + 'mailable_type' => Invoice::class, + 'type' => self::REMINDER_MAIL_TYPE, + 'from' => $template?->from_email ?? (string) config('mail.from.address'), + 'to' => $recipient, + 'cc' => '', + 'bcc' => '', + 'subject' => $subject, + 'body' => $body, + 'attach_pdf' => true, + 'is_sent' => true, + 'sent_at' => now(), + ]); + } + + /** + * The timestamp of the most recently sent reminder for this invoice, or + * null if none has been sent yet. Sourced from the invoice's own + * MailQueue history rather than a dedicated invoice column. + */ + public function lastReminderSentAt(Invoice $invoice): ?Carbon + { + $lastReminder = $invoice->mailQueue() + ->where('type', self::REMINDER_MAIL_TYPE) + ->latest('sent_at') + ->first(); + + return $lastReminder?->sent_at; + } + /** * Render the invoice document markup used by both the PDF driver and * the on-screen preview. diff --git a/Modules/Invoices/Tests/Feature/SendReminderActionTest.php b/Modules/Invoices/Tests/Feature/SendReminderActionTest.php new file mode 100644 index 000000000..72ce69134 --- /dev/null +++ b/Modules/Invoices/Tests/Feature/SendReminderActionTest.php @@ -0,0 +1,316 @@ +company->id) + ->where('title', 'invoice_reminder') + ->update([ + 'subject' => 'Reminder: {{ invoice.number }}', + 'body' => 'Dear {{ customer.name }}, invoice #{{ invoice.number }} for {{ invoice.total_formatted }} is overdue.', + ]); + + $invoice = $this->createOverdueInvoice(['invoice_total' => 150]); + + /* Act */ + $component = Livewire::actingAs($this->user) + ->test(EditInvoice::class, ['record' => $invoice->id]) + ->assertSuccessful() + ->mountAction('send_reminder'); + + /* Assert */ + $component->assertActionDataSet([ + 'recipient' => 'customer@example.com', + 'subject' => 'Reminder: INV-987654', + 'body' => "Dear {$invoice->customer->company_name}, invoice #INV-987654 for 150.00 is overdue.", + ]); + } + + #[Test] + #[Group('crud')] + public function it_falls_back_to_a_default_subject_and_blank_body_without_a_template(): void + { + /* Arrange */ + EmailTemplate::forCompany($this->company->id)->where('title', 'invoice_reminder')->delete(); + + $invoice = $this->createOverdueInvoice(); + + /* Act */ + $component = Livewire::actingAs($this->user) + ->test(EditInvoice::class, ['record' => $invoice->id]) + ->assertSuccessful() + ->mountAction('send_reminder'); + + /* Assert */ + $component->assertActionDataSet([ + 'recipient' => 'customer@example.com', + 'subject' => 'Payment reminder — Invoice #INV-987654', + 'body' => '', + ]); + } + + #[Test] + #[Group('crud')] + public function it_hides_the_action_without_the_email_invoices_permission(): void + { + /* Arrange */ + $this->user->syncRoles([]); + $this->user->givePermissionTo([ + Permission::VIEW_INVOICES->value, + Permission::EDIT_INVOICES->value, + ]); + $invoice = $this->createOverdueInvoice(); + + /* Act */ + $component = Livewire::actingAs($this->user) + ->test(EditInvoice::class, ['record' => $invoice->id]); + + /* Assert */ + $component + ->assertSuccessful() + ->assertActionHidden('send_reminder'); + } + + #[Test] + #[Group('crud')] + public function it_hides_the_send_reminder_action_for_an_invoice_that_is_not_yet_due(): void + { + /* Arrange */ + $customer = Relation::factory()->for($this->company)->customer()->create(); + $numbering = Numbering::factory()->for($this->company)->create(); + $notYetDue = $this->makeInvoice($customer, $numbering, [ + 'invoice_number' => 'INV-NOT-DUE', + 'invoice_status' => InvoiceStatus::SENT->value, + 'invoice_due_at' => '2026-06-09', + ]); + $overdue = $this->makeInvoice($customer, $numbering, [ + 'invoice_number' => 'INV-OVERDUE', + 'invoice_status' => InvoiceStatus::SENT->value, + 'invoice_due_at' => '2025-06-09', + ]); + + /* Act */ + $component = $this->listInvoices(); + + /* Assert */ + $component + ->assertSuccessful() + ->assertActionHidden(TestAction::make('send_reminder')->table($notYetDue)) + ->assertActionVisible(TestAction::make('send_reminder')->table($overdue)); + } + + #[Test] + #[Group('crud')] + public function it_disables_the_send_reminder_action_when_the_customer_has_no_email(): void + { + /* Arrange */ + $withoutEmail = Relation::factory()->for($this->company)->customer()->create(); + Communication::query() + ->where('communicationable_type', Contact::class) + ->whereIn('communicationable_id', $withoutEmail->contacts()->pluck('id')) + ->delete(); + + $withEmail = Relation::factory()->for($this->company)->customer()->create(); + $withEmail->primaryContact->communications()->create([ + 'company_id' => $this->company->id, + 'is_primary' => true, + 'communication_type' => CommunicationType::EMAIL->value, + 'communication_value' => 'billing@example.com', + ]); + + $numbering = Numbering::factory()->for($this->company)->create(); + + $invoiceWithoutEmail = $this->makeInvoice($withoutEmail, $numbering, [ + 'invoice_number' => 'INV-NO-EMAIL', + 'invoice_status' => InvoiceStatus::SENT->value, + 'invoice_due_at' => '2025-06-09', + ]); + $invoiceWithEmail = $this->makeInvoice($withEmail, $numbering, [ + 'invoice_number' => 'INV-WITH-EMAIL', + 'invoice_status' => InvoiceStatus::SENT->value, + 'invoice_due_at' => '2025-06-09', + ]); + + /* Act */ + $component = $this->listInvoices(); + + /* Assert */ + $component + ->assertSuccessful() + ->assertActionDisabled(TestAction::make('send_reminder')->table($invoiceWithoutEmail)) + ->assertActionEnabled(TestAction::make('send_reminder')->table($invoiceWithEmail)); + } + + #[Test] + #[Group('crud')] + public function it_shows_the_last_reminder_sent_date_on_the_invoice_edit_page(): void + { + /* Arrange */ + $invoice = $this->createOverdueInvoice(); + $invoice->mailQueue()->create([ + 'mailable_type' => Invoice::class, + 'type' => InvoiceService::REMINDER_MAIL_TYPE, + 'from' => 'billing@example.com', + 'to' => 'customer@example.com', + 'cc' => '', + 'bcc' => '', + 'subject' => 'Reminder', + 'body' => 'Reminder body', + 'attach_pdf' => true, + 'is_sent' => true, + 'sent_at' => now(), + ]); + + /* Act */ + $component = Livewire::actingAs($this->user) + ->test(EditInvoice::class, ['record' => $invoice->id]) + ->assertSuccessful(); + + /* Assert */ + $component->assertSee(now()->toDateString()); + } + + #[Test] + #[Group('crud')] + public function it_shows_never_when_no_reminder_has_been_sent(): void + { + /* Arrange */ + $invoice = $this->createOverdueInvoice(); + + /* Act */ + $component = Livewire::actingAs($this->user) + ->test(EditInvoice::class, ['record' => $invoice->id]) + ->assertSuccessful(); + + /* Assert */ + $component->assertSee(trans('ip.reminder_never_sent')); + } + + // dompdf/dompdf is in composer.lock but not actually installed in the + // ip2-test-php:8.4 image's vendor tree (see InvoicePdfAndCreditNoteTest), + // so sendReminder()'s PDF attachment step cannot run in this environment. + #[Test] + #[Group('crud')] + #[Group('failing')] + public function it_sends_a_reminder_email_with_the_invoice_pdf_attached_for_an_overdue_invoice(): void + { + /* Arrange */ + Mail::fake(); + $invoice = $this->createOverdueInvoice(); + + /* Act */ + app(InvoiceService::class)->sendReminder($invoice, 'customer@example.com', 'Reminder', 'Body'); + + /* Assert */ + Mail::assertQueued(InvoiceReminderMailable::class, fn ($mail) => count($mail->attachments()) === 1); + } + + #[Test] + #[Group('crud')] + #[Group('failing')] + public function it_logs_a_mail_queue_entry_of_type_reminder_when_a_reminder_is_sent(): void + { + /* Arrange */ + Mail::fake(); + $invoice = $this->createOverdueInvoice(); + + /* Act */ + app(InvoiceService::class)->sendReminder($invoice, 'customer@example.com', 'Reminder', 'Body'); + + /* Assert */ + $this->assertDatabaseHas('mail_queue', [ + 'mailable_id' => $invoice->id, + 'mailable_type' => Invoice::class, + 'type' => InvoiceService::REMINDER_MAIL_TYPE, + ]); + $this->assertNotNull($invoice->mailQueue()->where('type', InvoiceService::REMINDER_MAIL_TYPE)->first()->sent_at); + } + + #[Test] + #[Group('crud')] + #[Group('failing')] + public function it_creates_a_separate_mail_queue_entry_for_each_reminder_sent(): void + { + /* Arrange */ + Mail::fake(); + $invoice = $this->createOverdueInvoice(); + + /* Act */ + app(InvoiceService::class)->sendReminder($invoice, 'customer@example.com', 'Reminder', 'Body'); + app(InvoiceService::class)->sendReminder($invoice, 'customer@example.com', 'Reminder', 'Body'); + + /* Assert */ + $this->assertSame(2, $invoice->mailQueue()->where('type', InvoiceService::REMINDER_MAIL_TYPE)->count()); + } + + private function createOverdueInvoice(array $attributes = []): Invoice + { + $customer = Relation::factory()->for($this->company)->customer()->create(); + $contact = $customer->contacts()->create([ + 'company_id' => $this->company->id, + 'first_name' => 'Jane', + 'last_name' => 'Doe', + ]); + $contact->communications()->create([ + 'company_id' => $this->company->id, + 'is_primary' => true, + 'communication_type' => CommunicationType::EMAIL->value, + 'communication_value' => 'customer@example.com', + ]); + $numbering = Numbering::factory()->for($this->company)->create(); + + return $this->makeInvoice($customer, $numbering, array_merge([ + 'invoice_status' => InvoiceStatus::SENT->value, + 'invoice_due_at' => '2025-06-09', + ], $attributes)); + } + + private function makeInvoice(Relation $customer, Numbering $numbering, array $attributes = []): Invoice + { + return Invoice::factory()->for($this->company)->create(array_merge([ + 'invoice_number' => 'INV-987654', + 'customer_id' => $customer->getKey(), + 'numbering_id' => $numbering->getKey(), + 'user_id' => $this->user->id, + 'is_read_only' => false, + 'invoiced_at' => '2025-05-10', + ], $attributes)); + } + + private function listInvoices() + { + return Livewire::actingAs($this->user) + ->test(ListInvoices::class, ['tenant' => Str::lower($this->company->search_code)]); + } +} diff --git a/resources/lang/en/ip.php b/resources/lang/en/ip.php index 81552e2da..6532658e5 100644 --- a/resources/lang/en/ip.php +++ b/resources/lang/en/ip.php @@ -649,6 +649,12 @@ 'email_invoice' => 'Email Invoice', 'email_invoice_default_subject' => 'Invoice #:number', 'recipient' => 'Recipient', + 'send_reminder' => 'Send Reminder', + 'reminder_default_subject' => 'Payment reminder — Invoice #:number', + 'reminder_sent' => 'Reminder sent', + 'reminder_sent_successfully' => 'The payment reminder has been queued for delivery.', + 'last_reminder_sent' => 'Last reminder sent', + 'reminder_never_sent' => 'Never', 'filter_invoices' => 'Filter Invoices', 'generate_invoice_number_for_draft' => 'Generate the invoice number for draft invoices', 'invoice' => 'Invoice', From e37ffb81b7ae1d7048939e0e96485b7cd32b15b7 Mon Sep 17 00:00:00 2001 From: Niels Drost <47660417+nielsdrost7@users.noreply.github.com> Date: Fri, 31 Jul 2026 13:44:25 +0200 Subject: [PATCH 2/3] Update Modules/Invoices/Tests/Feature/SendReminderActionTest.php Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com> --- Modules/Invoices/Tests/Feature/SendReminderActionTest.php | 1 + 1 file changed, 1 insertion(+) diff --git a/Modules/Invoices/Tests/Feature/SendReminderActionTest.php b/Modules/Invoices/Tests/Feature/SendReminderActionTest.php index 72ce69134..34e8c8e80 100644 --- a/Modules/Invoices/Tests/Feature/SendReminderActionTest.php +++ b/Modules/Invoices/Tests/Feature/SendReminderActionTest.php @@ -29,6 +29,7 @@ class SendReminderActionTest extends AbstractCompanyPanelTestCase #[Group('crud')] public function it_prefills_the_modal_from_the_companys_reminder_email_template(): void { + /* Arrange */ /* * Every company is auto-bootstrapped with an "invoice_reminder" * EmailTemplate (see CompanyDefaultsBootstrapService), so update it From e34de2e102d56ec88191cfd2707e6714f7739e08 Mon Sep 17 00:00:00 2001 From: KhawarMehfooz Date: Fri, 31 Jul 2026 17:05:43 +0500 Subject: [PATCH 3/3] fix: address CodeRabbit review feedback on #480 - Align invoice_reminder template content between EmailTemplatesSeeder and CompanyDefaultsBootstrapService, and extract the duplicated from_email computation. - Extract InvoiceService::resolveTemplateDefaults() shared by resolveEmailDefaults()/resolveReminderDefaults()/sendReminder(), removing a redundant EmailTemplate query on every reminder send. - Add InvoiceService::hasReminderRecipient() and use it in SendReminderAction's disabled()/tooltip() instead of each re-resolving full template defaults. - Make InvoiceReminderMailable render the PDF lazily in attachments() at send time instead of serializing a pre-rendered binary into the queue payload. - Introduce a MailType backed enum for MailQueue::type, replacing the bare-string InvoiceService::REMINDER_MAIL_TYPE constant. Co-Authored-By: Claude Sonnet 5 --- Modules/Core/Enums/MailType.php | 29 +++++ Modules/Core/Models/MailQueue.php | 30 +++-- .../CompanyDefaultsBootstrapService.php | 8 +- .../Company/Actions/SendReminderAction.php | 8 +- .../Invoices/Mail/InvoiceReminderMailable.php | 15 ++- Modules/Invoices/Services/InvoiceService.php | 122 +++++++++--------- .../Tests/Feature/SendReminderActionTest.php | 9 +- 7 files changed, 131 insertions(+), 90 deletions(-) create mode 100644 Modules/Core/Enums/MailType.php diff --git a/Modules/Core/Enums/MailType.php b/Modules/Core/Enums/MailType.php new file mode 100644 index 000000000..b45d96654 --- /dev/null +++ b/Modules/Core/Enums/MailType.php @@ -0,0 +1,29 @@ + 'Reminder', + }; + } + + public function color(): string + { + return match ($this) { + self::REMINDER => 'warning', + }; + } +} diff --git a/Modules/Core/Models/MailQueue.php b/Modules/Core/Models/MailQueue.php index 056db7f4b..0d484ccc7 100644 --- a/Modules/Core/Models/MailQueue.php +++ b/Modules/Core/Models/MailQueue.php @@ -5,22 +5,23 @@ use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Relations\MorphTo; use Illuminate\Support\Carbon; +use Modules\Core\Enums\MailType; /** - * @property int $id - * @property int $mailable_id - * @property string $mailable_type - * @property string|null $type - * @property string $from - * @property string $to - * @property string $cc - * @property string $bcc - * @property string $subject - * @property string $body - * @property bool $attach_pdf - * @property bool $is_sent - * @property string|null $error - * @property Carbon|null $sent_at + * @property int $id + * @property int $mailable_id + * @property string $mailable_type + * @property MailType|null $type + * @property string $from + * @property string $to + * @property string $cc + * @property string $bcc + * @property string $subject + * @property string $body + * @property bool $attach_pdf + * @property bool $is_sent + * @property string|null $error + * @property Carbon|null $sent_at */ class MailQueue extends Model { @@ -32,6 +33,7 @@ class MailQueue extends Model 'attach_pdf' => 'bool', 'is_sent' => 'bool', 'sent_at' => 'datetime', + 'type' => MailType::class, ]; protected $guarded = []; diff --git a/Modules/Core/Services/CompanyDefaultsBootstrapService.php b/Modules/Core/Services/CompanyDefaultsBootstrapService.php index 155957001..cb4b1dd9e 100644 --- a/Modules/Core/Services/CompanyDefaultsBootstrapService.php +++ b/Modules/Core/Services/CompanyDefaultsBootstrapService.php @@ -43,6 +43,8 @@ public static function bootstrap(int $companyId): void $numberingData ); + $fromEmail = 'billing@' . mb_strtolower(preg_replace('/[^A-Za-z0-9]/', '', $company->name)) . '.com'; + // Create default email template EmailTemplate::query()->firstOrCreate( [ @@ -53,7 +55,7 @@ public static function bootstrap(int $companyId): void 'subject' => 'Invoice #{{ invoice.number }}', 'body' => 'Please find your invoice #{{ invoice.number }} attached.', 'from_name' => $company->name, - 'from_email' => 'billing@' . mb_strtolower(preg_replace('/[^A-Za-z0-9]/', '', $company->name)) . '.com', + 'from_email' => $fromEmail, 'cc' => null, 'bcc' => null, ] @@ -67,9 +69,9 @@ public static function bootstrap(int $companyId): void ], [ 'subject' => 'Payment reminder — Invoice #{{ invoice.number }}', - 'body' => 'Invoice #{{ invoice.number }} is overdue. Please arrange payment at your earliest convenience.', + 'body' => "Dear {{ customer.name }},\n\nThis is a friendly reminder that invoice #{{ invoice.number }} for {{ invoice.total_formatted }} was due on {{ invoice.due_date_formatted }} and remains unpaid.\n\nPlease arrange payment at your earliest convenience. The invoice is attached for your reference.\n\nThank you,\n{{ company.name }}", 'from_name' => $company->name, - 'from_email' => 'billing@' . mb_strtolower(preg_replace('/[^A-Za-z0-9]/', '', $company->name)) . '.com', + 'from_email' => $fromEmail, 'cc' => null, 'bcc' => null, ] diff --git a/Modules/Invoices/Filament/Company/Actions/SendReminderAction.php b/Modules/Invoices/Filament/Company/Actions/SendReminderAction.php index e0d33f055..a01f8746c 100644 --- a/Modules/Invoices/Filament/Company/Actions/SendReminderAction.php +++ b/Modules/Invoices/Filament/Company/Actions/SendReminderAction.php @@ -30,10 +30,10 @@ public static function make(): Action return Action::make('send_reminder') ->label(trans('ip.send_reminder')) ->icon(Heroicon::OutlinedBellAlert) - ->disabled(fn (Invoice $record): bool => blank(app(InvoiceService::class)->resolveReminderDefaults($record)['recipient'])) - ->tooltip(fn (Invoice $record): ?string => blank(app(InvoiceService::class)->resolveReminderDefaults($record)['recipient']) - ? trans('ip.customer_has_no_email') - : null) + ->disabled(fn (Invoice $record): bool => ! app(InvoiceService::class)->hasReminderRecipient($record)) + ->tooltip(fn (Invoice $record): ?string => app(InvoiceService::class)->hasReminderRecipient($record) + ? null + : trans('ip.customer_has_no_email')) ->schema(function (Invoice $record) { $defaults = app(InvoiceService::class)->resolveReminderDefaults($record); diff --git a/Modules/Invoices/Mail/InvoiceReminderMailable.php b/Modules/Invoices/Mail/InvoiceReminderMailable.php index 4701b9333..6a69fdfc1 100644 --- a/Modules/Invoices/Mail/InvoiceReminderMailable.php +++ b/Modules/Invoices/Mail/InvoiceReminderMailable.php @@ -9,7 +9,9 @@ use Illuminate\Mail\Mailables\Content; use Illuminate\Mail\Mailables\Envelope; use Illuminate\Queue\SerializesModels; +use Modules\Core\Support\PDF\PDFFactory; use Modules\Invoices\Models\Invoice; +use Modules\Invoices\Services\InvoiceService; class InvoiceReminderMailable extends Mailable implements ShouldQueue { @@ -20,7 +22,6 @@ public function __construct( public Invoice $invoice, public string $emailSubject, public string $bodyText, - public string $pdfBinary, ) {} public function envelope(): Envelope @@ -37,12 +38,20 @@ public function content(): Content ); } + /** + * Renders the PDF here, at send time, rather than in the caller ahead of + * queuing — a pre-rendered binary on a ShouldQueue mailable would get + * serialized into the queue payload (DB row / Redis value / SQS message) + * instead of being generated when the job actually runs. + */ public function attachments(): array { - $filename = ($this->invoice->invoice_number ?: 'invoice-' . $this->invoice->id) . '.pdf'; + $filename = ($this->invoice->invoice_number ?: 'invoice-' . $this->invoice->id) . '.pdf'; + $html = app(InvoiceService::class)->renderHtml($this->invoice); + $pdfBinary = PDFFactory::create()->getOutput($html); return [ - Attachment::fromData(fn () => $this->pdfBinary, $filename) + Attachment::fromData(fn () => $pdfBinary, $filename) ->withMime('application/pdf'), ]; } diff --git a/Modules/Invoices/Services/InvoiceService.php b/Modules/Invoices/Services/InvoiceService.php index 347392294..e46b5f8cd 100644 --- a/Modules/Invoices/Services/InvoiceService.php +++ b/Modules/Invoices/Services/InvoiceService.php @@ -8,6 +8,7 @@ use Illuminate\Support\Str; use InvalidArgumentException; use Modules\Clients\Enums\CommunicationType; +use Modules\Core\Enums\MailType; use Modules\Core\Models\EmailTemplate; use Modules\Core\Services\BaseService; use Modules\Core\Support\DateHelpers; @@ -32,11 +33,6 @@ class InvoiceService extends BaseService */ public const INVOICE_REMINDER_EMAIL_TEMPLATE_TITLE = 'invoice_reminder'; - /** - * MailQueue#type value used to identify reminder emails. - */ - public const REMINDER_MAIL_TYPE = 'reminder'; - public function model(): string { return Invoice::class; @@ -48,31 +44,10 @@ public function model(): string */ public function resolveEmailDefaults(Invoice $invoice): array { - $invoice->loadMissing(['customer', 'company']); + $defaults = $this->resolveTemplateDefaults($invoice, self::INVOICE_EMAIL_TEMPLATE_TITLE, 'ip.email_invoice_default_subject'); + unset($defaults['template']); - $template = EmailTemplate::forCompany($invoice->company_id) - ->where('title', self::INVOICE_EMAIL_TEMPLATE_TITLE) - ->first(); - - $placeholders = [ - 'invoice.number' => $invoice->invoice_number, - 'invoice.total_formatted' => number_format((float) $invoice->invoice_total, 2), - 'invoice.due_date_formatted' => DateHelpers::formatDate($invoice->invoice_due_at), - 'customer.name' => $invoice->customer?->company_name, - 'company.name' => $invoice->company?->name, - ]; - - $defaultSubject = trans('ip.email_invoice_default_subject', ['number' => $invoice->invoice_number]); - - return [ - 'recipient' => $this->resolveInvoiceRecipientEmail($invoice), - 'subject' => $template?->subject - ? EmailTemplatePreview::render($template->subject, $placeholders) - : $defaultSubject, - 'body' => $template?->body - ? EmailTemplatePreview::render($template->body, $placeholders) - : '', - ]; + return $defaults; } public function createInvoice(array $data): Invoice @@ -278,31 +253,21 @@ public function sendInvoiceEmail(Invoice $invoice, string $recipient, string $su */ public function resolveReminderDefaults(Invoice $invoice): array { - $invoice->loadMissing(['customer', 'company']); - - $template = EmailTemplate::forCompany($invoice->company_id) - ->where('title', self::INVOICE_REMINDER_EMAIL_TEMPLATE_TITLE) - ->first(); - - $placeholders = [ - 'invoice.number' => $invoice->invoice_number, - 'invoice.total_formatted' => number_format((float) $invoice->invoice_total, 2), - 'invoice.due_date_formatted' => DateHelpers::formatDate($invoice->invoice_due_at), - 'customer.name' => $invoice->customer?->company_name, - 'company.name' => $invoice->company?->name, - ]; + $defaults = $this->resolveTemplateDefaults($invoice, self::INVOICE_REMINDER_EMAIL_TEMPLATE_TITLE, 'ip.reminder_default_subject'); + unset($defaults['template']); - $defaultSubject = trans('ip.reminder_default_subject', ['number' => $invoice->invoice_number]); + return $defaults; + } - return [ - 'recipient' => $this->resolveInvoiceRecipientEmail($invoice), - 'subject' => $template?->subject - ? EmailTemplatePreview::render($template->subject, $placeholders) - : $defaultSubject, - 'body' => $template?->body - ? EmailTemplatePreview::render($template->body, $placeholders) - : '', - ]; + /** + * Lightweight check for whether this invoice's customer has a resolvable + * email address, without the EmailTemplate lookup/placeholder rendering + * that resolveReminderDefaults() does. Intended for cheap per-row + * disabled()/tooltip() checks on the "Send Reminder" table/header action. + */ + public function hasReminderRecipient(Invoice $invoice): bool + { + return filled($this->resolveInvoiceRecipientEmail($invoice)); } /** @@ -313,7 +278,7 @@ public function resolveReminderDefaults(Invoice $invoice): array */ public function sendReminder(Invoice $invoice, ?string $recipient = null, ?string $subject = null, ?string $body = null): void { - $defaults = $this->resolveReminderDefaults($invoice); + $defaults = $this->resolveTemplateDefaults($invoice, self::INVOICE_REMINDER_EMAIL_TEMPLATE_TITLE, 'ip.reminder_default_subject'); $recipient ??= $defaults['recipient']; $subject ??= $defaults['subject']; @@ -323,17 +288,12 @@ public function sendReminder(Invoice $invoice, ?string $recipient = null, ?strin throw new InvalidArgumentException(trans('ip.customer_has_no_email')); } - $template = EmailTemplate::forCompany($invoice->company_id) - ->where('title', self::INVOICE_REMINDER_EMAIL_TEMPLATE_TITLE) - ->first(); - $pdfBinary = PDFFactory::create()->getOutput($this->renderHtml($invoice)); - - Mail::to($recipient)->queue(new InvoiceReminderMailable($invoice, $subject, $body, $pdfBinary)); + Mail::to($recipient)->queue(new InvoiceReminderMailable($invoice, $subject, $body)); $invoice->mailQueue()->create([ 'mailable_type' => Invoice::class, - 'type' => self::REMINDER_MAIL_TYPE, - 'from' => $template?->from_email ?? (string) config('mail.from.address'), + 'type' => MailType::REMINDER, + 'from' => $defaults['template']?->from_email ?? (string) config('mail.from.address'), 'to' => $recipient, 'cc' => '', 'bcc' => '', @@ -353,7 +313,7 @@ public function sendReminder(Invoice $invoice, ?string $recipient = null, ?strin public function lastReminderSentAt(Invoice $invoice): ?Carbon { $lastReminder = $invoice->mailQueue() - ->where('type', self::REMINDER_MAIL_TYPE) + ->where('type', MailType::REMINDER) ->latest('sent_at') ->first(); @@ -451,6 +411,44 @@ public function createCreditNote(Invoice $invoice): Invoice }); } + /** + * Shared resolution logic for the "Email Invoice" and "Send Reminder" + * modals: loads the named company EmailTemplate once, renders its + * subject/body against this invoice, and resolves the recipient. Returns + * the EmailTemplate alongside the rendered defaults so callers that also + * need template fields (e.g. sendReminder()'s from_email) don't have to + * re-query it. + */ + private function resolveTemplateDefaults(Invoice $invoice, string $templateTitle, string $defaultSubjectTransKey): array + { + $invoice->loadMissing(['customer', 'company']); + + $template = EmailTemplate::forCompany($invoice->company_id) + ->where('title', $templateTitle) + ->first(); + + $placeholders = [ + 'invoice.number' => $invoice->invoice_number, + 'invoice.total_formatted' => number_format((float) $invoice->invoice_total, 2), + 'invoice.due_date_formatted' => DateHelpers::formatDate($invoice->invoice_due_at), + 'customer.name' => $invoice->customer?->company_name, + 'company.name' => $invoice->company?->name, + ]; + + $defaultSubject = trans($defaultSubjectTransKey, ['number' => $invoice->invoice_number]); + + return [ + 'template' => $template, + 'recipient' => $this->resolveInvoiceRecipientEmail($invoice), + 'subject' => $template?->subject + ? EmailTemplatePreview::render($template->subject, $placeholders) + : $defaultSubject, + 'body' => $template?->body + ? EmailTemplatePreview::render($template->body, $placeholders) + : '', + ]; + } + /** * Walk the invoice's customer → contacts → communications chain and * return the first email address found, preferring a primary one. diff --git a/Modules/Invoices/Tests/Feature/SendReminderActionTest.php b/Modules/Invoices/Tests/Feature/SendReminderActionTest.php index 34e8c8e80..6bb5ac2e5 100644 --- a/Modules/Invoices/Tests/Feature/SendReminderActionTest.php +++ b/Modules/Invoices/Tests/Feature/SendReminderActionTest.php @@ -10,6 +10,7 @@ use Modules\Clients\Models\Communication; use Modules\Clients\Models\Contact; use Modules\Clients\Models\Relation; +use Modules\Core\Enums\MailType; use Modules\Core\Enums\Permission; use Modules\Core\Models\EmailTemplate; use Modules\Core\Models\Numbering; @@ -181,7 +182,7 @@ public function it_shows_the_last_reminder_sent_date_on_the_invoice_edit_page(): $invoice = $this->createOverdueInvoice(); $invoice->mailQueue()->create([ 'mailable_type' => Invoice::class, - 'type' => InvoiceService::REMINDER_MAIL_TYPE, + 'type' => MailType::REMINDER, 'from' => 'billing@example.com', 'to' => 'customer@example.com', 'cc' => '', @@ -253,9 +254,9 @@ public function it_logs_a_mail_queue_entry_of_type_reminder_when_a_reminder_is_s $this->assertDatabaseHas('mail_queue', [ 'mailable_id' => $invoice->id, 'mailable_type' => Invoice::class, - 'type' => InvoiceService::REMINDER_MAIL_TYPE, + 'type' => MailType::REMINDER->value, ]); - $this->assertNotNull($invoice->mailQueue()->where('type', InvoiceService::REMINDER_MAIL_TYPE)->first()->sent_at); + $this->assertNotNull($invoice->mailQueue()->where('type', MailType::REMINDER)->first()->sent_at); } #[Test] @@ -272,7 +273,7 @@ public function it_creates_a_separate_mail_queue_entry_for_each_reminder_sent(): app(InvoiceService::class)->sendReminder($invoice, 'customer@example.com', 'Reminder', 'Body'); /* Assert */ - $this->assertSame(2, $invoice->mailQueue()->where('type', InvoiceService::REMINDER_MAIL_TYPE)->count()); + $this->assertSame(2, $invoice->mailQueue()->where('type', MailType::REMINDER)->count()); } private function createOverdueInvoice(array $attributes = []): Invoice