You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
{{ message }}
Repository navigation
[Invoices]: Send invoice reminders for overdue invoices #480
Chasing overdue invoices by hand is time-consuming and easy to forget. A one-click reminder from within InvoicePlane — using the customer's existing email and the invoice details — removes that friction and creates an audit trail of reminder activity.
User story
As a user_admin, I want to send a payment reminder email for any overdue invoice so that I can prompt customers to pay without switching to my email client, and keep a record of every reminder sent.
Acceptance criteria
A Send reminder action is available on the invoice detail and list views for invoices with status Sent and a past due date.
The action opens a modal pre-filled with: recipient (customer.email), subject (e.g. Payment reminder — Invoice #123), and body from the company's reminder email template.
The user can edit all fields before sending.
On send: the invoice PDF is attached.
A MailQueue record is created with type reminder, sent_at timestamp, and recipient.
The invoice detail shows a "Last reminder sent" badge with the date.
If the customer has no email, the action is disabled with a tooltip.
Sending multiple reminders is allowed; each creates a separate MailQueue entry.
Arrange / Act / Assert
Arrange
Invoice #200 — status Sent, due_at = 30 days ago, customer has valid email.
Mail::fake() configured.
Act
Trigger "Send reminder" → confirm modal.
Assert
One mailable dispatched to customer.email.
MailQueue record exists: mailable_id = 200, type reminder, sent_at populated.
Invoice detail shows last reminder date.
Invoice with future due date: action is hidden or disabled.
Requires: email template system, MailQueue model, PDF generation.
changed the title [-]As a user_admin, I want to send invoice reminders for overdue invoices.[/-][+][Invoices]: Send invoice reminders for overdue invoices[/+]on Jun 13, 2026
SMART Plan — Send invoice reminders for overdue invoices
Context/Epic: Invoices
Specific
Add a "Send reminder" action for overdue invoices. This needs several building blocks that don't exist yet in develop, so it's best sequenced after (or alongside) the related #150 email work also in this batch. Concretely: InvoiceStatus::OVERDUE already exists as an enum case (Modules/Invoices/Enums/InvoiceStatus.php), but nothing transitions an invoice to it automatically. mail_queue (Modules/Core/Database/Migrations/2023_08_20_113330_create_mail_queue_table.php) has no type or sent_at column — only mailable_id/mailable_type, from/to/cc/bcc, subject, body, attach_pdf, is_sent, error, and the model itself has $timestamps = false, so "sent_at timestamp" needs a new column, not just enabling timestamps. Modules\Core\Services\MailQueueService is an empty stub — nothing populates mail_queue for any document type today. Modules\Core\Services\PdfGenerationService is an empty stub in develop (a full implementation exists on the unmerged feature/130-report-builder branch — see #411 in this batch), so "the invoice PDF is attached" has no generator to call yet. And critically: Relation (the client model) has no email column at all — Modules/Clients/Models/Relation.php's getCustomerEmailAttribute() reads $this->email, which isn't a real database column; a client's email address actually lives in the polymorphic Communication model (Relation::communications(): MorphMany, filtered by communication_type = 'email'). Every acceptance criterion in this issue that says "customer.email" needs to resolve through that relation, not a direct attribute.
Measurable
A getPrimaryEmailAttribute() (or similar) is added to Relation that actually resolves through communications(), since $customer->email does not work today.
Add a real primary_email-style accessor/method to Relation that goes through communications(), since no code path today can actually get a client's email address as a plain string.
Add the "Send reminder" Action to InvoicesTable, mirroring the existing "send email" action's structure, visible only when invoice_due_at is in the past and a resolved email exists.
Chasing overdue invoices by hand is time-consuming and easy to forget; this removes that friction and creates an auditable reminder history — but it depends on fixing the client-email resolution gap and building out the MailQueue/PDF pipeline shared with #150 and #411 first.
Activity
[-]As a user_admin, I want to send invoice reminders for overdue invoices.[/-][+][Invoices]: Send invoice reminders for overdue invoices[/+]nielsdrost7 commentedon Jul 16, 2026
SMART Plan — Send invoice reminders for overdue invoices
Specific
Add a "Send reminder" action for overdue invoices. This needs several building blocks that don't exist yet in
develop, so it's best sequenced after (or alongside) the related #150 email work also in this batch. Concretely:InvoiceStatus::OVERDUEalready exists as an enum case (Modules/Invoices/Enums/InvoiceStatus.php), but nothing transitions an invoice to it automatically.mail_queue(Modules/Core/Database/Migrations/2023_08_20_113330_create_mail_queue_table.php) has notypeorsent_atcolumn — onlymailable_id/mailable_type,from/to/cc/bcc,subject,body,attach_pdf,is_sent,error, and the model itself has$timestamps = false, so "sent_at timestamp" needs a new column, not just enabling timestamps.Modules\Core\Services\MailQueueServiceis an empty stub — nothing populatesmail_queuefor any document type today.Modules\Core\Services\PdfGenerationServiceis an empty stub indevelop(a full implementation exists on the unmergedfeature/130-report-builderbranch — see #411 in this batch), so "the invoice PDF is attached" has no generator to call yet. And critically:Relation(the client model) has noemailcolumn at all —Modules/Clients/Models/Relation.php'sgetCustomerEmailAttribute()reads$this->email, which isn't a real database column; a client's email address actually lives in the polymorphicCommunicationmodel (Relation::communications(): MorphMany, filtered bycommunication_type = 'email'). Every acceptance criterion in this issue that says "customer.email" needs to resolve through that relation, not a direct attribute.Measurable
getPrimaryEmailAttribute()(or similar) is added toRelationthat actually resolves throughcommunications(), since$customer->emaildoes not work today.it_resolves_the_clients_primary_email_through_the_communications_relation()#[Test] public function it_resolves_the_clients_primary_email_through_the_communications_relation(): void { /* Arrange */ $client = Relation::factory()->for($this->company)->create(); $client->communications()->create([ 'company_id' => $this->company->id, 'communication_type' => CommunicationType::EMAIL->value, 'communication_value' => 'client@example.com', 'is_primary' => true, ]); /* Act */ $email = $client->fresh()->primary_email; /* Assert */ $this->assertSame('client@example.com', $email); }#[Test] public function it_hides_the_send_reminder_action_when_the_client_has_no_email(): void { /* Arrange */ $client = Relation::factory()->for($this->company)->create(); $invoice = Invoice::factory()->for($this->company)->create([ 'customer_id' => $client->id, 'invoice_status' => InvoiceStatus::SENT->value, 'invoice_due_at' => now()->subDays(30), ]); /* Act */ $component = $this->testLivewire(ListInvoices::class); /* Assert */ $component->assertTableActionHidden('send reminder', $invoice); }it_sends_a_reminder_email_with_the_invoice_pdf_attached_for_an_overdue_invoice()#[Test] public function it_sends_a_reminder_email_with_the_invoice_pdf_attached_for_an_overdue_invoice(): void { /* Arrange */ Mail::fake(); $invoice = Invoice::factory()->for($this->company)->create([ 'invoice_status' => InvoiceStatus::SENT->value, 'invoice_due_at' => now()->subDays(30), ]); /* Act */ app(InvoiceService::class)->sendReminder($invoice); /* Assert */ Mail::assertSent(fn ($mail) => count($mail->attachments) === 1); }MailQueuerecord is created withtype = 'reminder'andsent_atpopulated (both new columns).it_logs_a_mail_queue_entry_of_type_reminder_when_a_reminder_is_sent()#[Test] public function it_logs_a_mail_queue_entry_of_type_reminder_when_a_reminder_is_sent(): void { /* Arrange */ Mail::fake(); $invoice = Invoice::factory()->for($this->company)->create([ 'invoice_status' => InvoiceStatus::SENT->value, 'invoice_due_at' => now()->subDays(30), ]); /* Act */ app(InvoiceService::class)->sendReminder($invoice); /* Assert */ $this->assertDatabaseHas('mail_queue', [ 'mailable_id' => $invoice->id, 'mailable_type' => Invoice::class, 'type' => 'reminder', ]); $this->assertNotNull($invoice->mailQueue()->where('type', 'reminder')->first()->sent_at); }it_shows_the_last_reminder_sent_date_on_the_invoice_detail_view()#[Test] public function it_shows_the_last_reminder_sent_date_on_the_invoice_detail_view(): void { /* Arrange */ $invoice = Invoice::factory()->for($this->company)->create(); $invoice->mailQueue()->create([ 'company_id' => $this->company->id, 'from' => 'a@a.com', 'to' => 'b@b.com', 'cc' => '', 'bcc' => '', 'subject' => 'Reminder', 'body' => '...', 'attach_pdf' => true, 'is_sent' => true, 'type' => 'reminder', 'sent_at' => now(), ]); /* Act */ $response = $this->get(route('filament.company.resources.invoices.view', [ 'tenant' => Str::lower($this->company->search_code), 'record' => $invoice->id, ])); /* Assert */ $response->assertSee(now()->toDateString()); }MailQueuerow each time (no overwrite/upsert).it_creates_a_separate_mail_queue_entry_for_each_reminder_sent()#[Test] public function it_creates_a_separate_mail_queue_entry_for_each_reminder_sent(): void { /* Arrange */ Mail::fake(); $invoice = Invoice::factory()->for($this->company)->create([ 'invoice_status' => InvoiceStatus::SENT->value, 'invoice_due_at' => now()->subDays(30), ]); /* Act */ app(InvoiceService::class)->sendReminder($invoice); app(InvoiceService::class)->sendReminder($invoice); /* Assert */ $this->assertSame(2, $invoice->mailQueue()->where('type', 'reminder')->count()); }Achievable
type(string, nullable) andsent_at(timestamp, nullable) tomail_queue— coordinate with [Invoices] Create/update MailQueue record to log email delivery (timestamp, recipient, status) #527, which needs the samesent_atcolumn for the regular invoice-email flow, so this shouldn't be two competing migrations.primary_email-style accessor/method toRelationthat goes throughcommunications(), since no code path today can actually get a client's email address as a plain string.MailQueueService(currently an empty stub) with alog(...)method shared between this reminder flow and [Invoices] Create/update MailQueue record to log email delivery (timestamp, recipient, status) #527's invoice-email logging.ActiontoInvoicesTable, mirroring the existing "send email" action's structure, visible only wheninvoice_due_atis in the past and a resolved email exists.PdfGenerationServiceonce [Invoices]: Ability to select from multiple invoice templates #411/[Reports]: Report Builder — bands + bricks #130 is merged; until then this can't fully satisfy the "PDF is attached" criterion.Relevant
Chasing overdue invoices by hand is time-consuming and easy to forget; this removes that friction and creates an auditable reminder history — but it depends on fixing the client-email resolution gap and building out the
MailQueue/PDF pipeline shared with #150 and #411 first.Time-bound
Sprint 8, sequenced after #150's email pipeline (#522/#524/#526/#527/#529/#531) and #411/#130's PDF pipeline land, since this reuses both.
fix: address CodeRabbit review feedback on InvoicePlane#480
fix: address CodeRabbit review feedback on #480
feat: Send invoice reminders for overdue invoices. Closes InvoicePlan…
feat: Send invoice reminders for overdue invoices. Closes InvoicePlan…
fix: address CodeRabbit review feedback on #480