Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
<?php

use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;

return new class () extends Migration {
public function up(): void
{
Schema::table('mail_queue', function (Blueprint $table): void {
$table->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']);
});
}
};
6 changes: 6 additions & 0 deletions Modules/Core/Database/Seeders/EmailTemplatesSeeder.php
Original file line number Diff line number Diff line change
Expand Up @@ -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 }}',
Expand Down
29 changes: 29 additions & 0 deletions Modules/Core/Enums/MailType.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
<?php

namespace Modules\Core\Enums;

use Modules\Core\Contracts\LabeledEnum;

enum MailType: string implements LabeledEnum
{
case REMINDER = 'reminder';

public static function values(): array
{
return array_column(self::cases(), 'value');
}

public function label(): string
{
return match ($this) {
self::REMINDER => 'Reminder',
};
}

public function color(): string
{
return match ($this) {
self::REMINDER => 'warning',
};
}
}
30 changes: 18 additions & 12 deletions Modules/Core/Models/MailQueue.php
Original file line number Diff line number Diff line change
Expand Up @@ -4,20 +4,24 @@

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 $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 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
{
Expand All @@ -28,6 +32,8 @@ class MailQueue extends Model
protected $casts = [
'attach_pdf' => 'bool',
'is_sent' => 'bool',
'sent_at' => 'datetime',
'type' => MailType::class,
];

protected $guarded = [];
Expand Down
20 changes: 19 additions & 1 deletion Modules/Core/Services/CompanyDefaultsBootstrapService.php
Original file line number Diff line number Diff line change
Expand Up @@ -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(
[
Expand All @@ -53,7 +55,23 @@ 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,
]
);

// Create default reminder email template
EmailTemplate::query()->firstOrCreate(
[
'company_id' => $company->id,
'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 }}",
'from_name' => $company->name,
'from_email' => $fromEmail,
'cc' => null,
'bcc' => null,
]
Expand Down
74 changes: 74 additions & 0 deletions Modules/Invoices/Filament/Company/Actions/SendReminderAction.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
<?php

namespace Modules\Invoices\Filament\Company\Actions;

use Filament\Actions\Action;
use Filament\Forms\Components\Textarea;
use Filament\Forms\Components\TextInput;
use Filament\Notifications\Notification;
use Filament\Support\Icons\Heroicon;
use Modules\Invoices\Enums\InvoiceStatus;
use Modules\Invoices\Models\Invoice;
use Modules\Invoices\Services\InvoiceService;

class SendReminderAction
{
/**
* True when the invoice is eligible for a reminder: sent/overdue with a due
* date in the past. Callers should AND this into their own ->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 => ! 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);

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();
});
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand Down Expand Up @@ -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)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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))
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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)
Expand Down
58 changes: 58 additions & 0 deletions Modules/Invoices/Mail/InvoiceReminderMailable.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
<?php

namespace Modules\Invoices\Mail;

use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Mail\Mailable;
use Illuminate\Mail\Mailables\Attachment;
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
{
use Queueable;
use SerializesModels;

public function __construct(
public Invoice $invoice,
public string $emailSubject,
public string $bodyText,
) {}

public function envelope(): Envelope
{
return new Envelope(
subject: $this->emailSubject,
);
}

public function content(): Content
{
return new Content(
htmlString: nl2br(e($this->bodyText)),
);
}

/**
* 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';
$html = app(InvoiceService::class)->renderHtml($this->invoice);
$pdfBinary = PDFFactory::create()->getOutput($html);

return [
Attachment::fromData(fn () => $pdfBinary, $filename)
->withMime('application/pdf'),
];
}
}
Loading
Loading