Skip to content

[Invoices]: Send invoice reminders for overdue invoices #480

Closed

Description

@nielsdrost7

Context

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.

Activity

  1. 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
  2. added this to the v2.0.0-alpha milestone on Jun 13, 2026
  3. nielsdrost7 commented on Jul 16, 2026

    @nielsdrost7
    CollaboratorAuthor

    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.
      • PHPUnit: 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);
        }
    • "Send reminder" is visible only for invoices with a past due date and an email on file; hidden/disabled otherwise.
      • Frontend/Livewire: As a user_admin I want to send a payment reminder for an overdue invoice so I can prompt the customer without leaving InvoicePlane.
        #[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);
        }
    • Sending a reminder dispatches one mailable to the client's resolved email and attaches the invoice PDF.
      • PHPUnit: 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);
        }
    • A MailQueue record is created with type = 'reminder' and sent_at populated (both new columns).
      • PHPUnit: 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);
        }
    • The invoice detail shows a "Last reminder sent" badge sourced from the invoice's own reminder history, not a new invoice column.
      • PHPUnit: 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());
        }
    • Sending multiple reminders creates a separate MailQueue row each time (no overwrite/upsert).
      • PHPUnit: 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

    1. Migration: add type (string, nullable) and sent_at (timestamp, nullable) to mail_queue — coordinate with [Invoices] Create/update MailQueue record to log email delivery (timestamp, recipient, status) #527, which needs the same sent_at column for the regular invoice-email flow, so this shouldn't be two competing migrations.
    2. 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.
    3. Implement MailQueueService (currently an empty stub) with a log(...) method shared between this reminder flow and [Invoices] Create/update MailQueue record to log email delivery (timestamp, recipient, status) #527's invoice-email logging.
    4. 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.
    5. Wire PDF attachment through PdfGenerationService once [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.

  4. added a commit that references this issue on Jul 31, 2026
    db9a3cb
  5. added 2 commits that reference this issue on Aug 17, 2026
    c419577
    051492b
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

Labels

refinedRefined with SMART plan / test suggestions

Type

No type

Projects

No projects

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions