Skip to content

Commit

Permalink
Add command sending a reminder for bookings which are not paid until …
Browse files Browse the repository at this point in the history
…the payment deadline, refactor booking confirmation (#76)

* Add command sending a reminder for bookings which are not paid until the payment deadline

* Add Unit tests
  • Loading branch information
patrickrobrecht authored Mar 6, 2025
1 parent 3063a9d commit 94d4db9
Show file tree
Hide file tree
Showing 11 changed files with 297 additions and 38 deletions.
99 changes: 99 additions & 0 deletions app/Console/Commands/SendPaymentRemindersCommand.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,99 @@
<?php

namespace App\Console\Commands;

use App\Models\Booking;
use App\Models\BookingOption;
use App\Notifications\PaymentReminderNotification;
use Carbon\Carbon;
use Illuminate\Console\Command;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Collection;
use Illuminate\Support\Facades\Log;
use Illuminate\Support\Facades\Notification;

class SendPaymentRemindersCommand extends Command
{
protected $signature = 'app:send-payment-reminders
{--dry-run : If set the reminders are only listed, but not actually sent.}';
protected $description = 'Send payment reminders for overdue bookings';

private bool $isDryRun = false;
private string $messageBuffer = '';

public function handle(): int
{
/** @var Collection<BookingOption> $bookingOptions */
$bookingOptions = BookingOption::query()
->whereNotNull('payment_due_days')
->whereHas(
'bookings',
fn (Builder $bookings) => $bookings
->whereNotNull('price')
->whereNull('paid_at')
->whereNull('deleted_at')
)
->with([
'event',
])
->orderBy('name')
->get();

if ($bookingOptions->isEmpty()) {
$this->info('There are no booking options with unpaid bookings to check.');
return self::SUCCESS;
}

$this->isDryRun = $this->option('dry-run');

$bookingOptions = $bookingOptions->sortBy([
'events.name',
'name',
]);
foreach ($bookingOptions as $bookingOption) {
$this->components->task(
$bookingOption->event->name . ', ' . $bookingOption->name,
fn () => $this->processBookingsForOption($bookingOption)
);
}
$this->info($this->messageBuffer);

return self::SUCCESS;
}

private function processBookingsForOption(BookingOption $bookingOption): bool
{
// Calculate which bookings have reached the due date already.
$bookedBefore = Carbon::today()->endOfDay()->subWeekdays($bookingOption->payment_due_days);

/** @var Collection<Booking> $bookingsReadyForReminder */
$bookingsReadyForReminder = $bookingOption->bookings()
->whereNotNull('price')
->whereNull('paid_at')
->where('booked_at', '<=', $bookedBefore)
->orderBy('booked_at')
->get();
if ($bookingsReadyForReminder->isEmpty()) {
$this->info('No reminders to send.');
return true;
}

$this->output->progressStart($bookingsReadyForReminder->count());

foreach ($bookingsReadyForReminder as $booking) {
if ($this->isDryRun) {
$message = "Reminder to {$booking->email} for booking {$booking->id} not sent because it's a dry run.";
} else {
Notification::route('mail', $booking->email)
->notify(new PaymentReminderNotification($booking));
$message = "Sent reminder to {$booking->email} for booking {$booking->id}.";
}
Log::info($message);
$this->messageBuffer .= $message . PHP_EOL;
$this->output->progressAdvance();
}

$this->output->progressFinish();
return true;
}
}
21 changes: 21 additions & 0 deletions app/Models/Booking.php
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
use App\Models\QueryBuilder\BuildsQueryFromRequest;
use App\Models\QueryBuilder\SortOptions;
use App\Models\Traits\HasAddress;
use App\Models\Traits\HasFullName;
use App\Models\Traits\HasPhone;
use App\Options\DeletedFilter;
use App\Options\FilterValue;
Expand All @@ -21,6 +22,7 @@
use Illuminate\Database\Eloquent\Relations\HasMany;
use Illuminate\Database\Eloquent\SoftDeletes;
use Illuminate\Http\UploadedFile;
use Illuminate\Notifications\Messages\MailMessage;
use Illuminate\Support\Arr;
use Illuminate\Support\Facades\Session;
use Spatie\QueryBuilder\AllowedFilter;
Expand Down Expand Up @@ -55,6 +57,7 @@ class Booking extends Model
use BuildsQueryFromRequest;
use HasAddress;
use HasFactory;
use HasFullName;
use HasPhone;
use SoftDeletes;

Expand Down Expand Up @@ -201,6 +204,24 @@ public function fillAndSave(array $validatedData): bool
return true;
}

public function prepareMailMessage(): MailMessage
{
$mail = new MailMessage();
$mail->greeting($this->bookedByUser->greeting ?? $this->greeting);

if (isset($this->bookedByUser) && $this->bookedByUser->email !== $this->email) {
$mail->cc($this->bookedByUser->email);
}

$organization = $this->bookingOption->event->organization;
if (isset($organization->email)) {
$mail->bcc($organization->email)
->replyTo($organization->email, $organization->name);
}

return $mail;
}

public function getGroup(Event $event): ?Group
{
return $this->groups->first(fn (Group $group) => $group->event_id === $event->id);
Expand Down
12 changes: 12 additions & 0 deletions app/Models/Organization.php
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@
use App\Options\ActiveStatus;
use App\Options\FilterValue;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Casts\Attribute;
use Illuminate\Database\Eloquent\Collection;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
Expand All @@ -31,6 +32,8 @@
* @property ?string $iban
* @property ?string $bank_name
*
* @property-read array $bank_account_lines {@see self::bankAccountLines()}
*
* @property Collection|Event[] $events {@see Organization::events()}
* @property Collection|EventSeries[] $eventSeries {@see self::eventSeries()}
*/
Expand Down Expand Up @@ -70,6 +73,15 @@ class Organization extends Model
'status' => ActiveStatus::class,
];

public function bankAccountLines(): Attribute
{
return Attribute::get(fn () => isset($this->iban, $this->bank_name) ? [
__('Account holder') . ': ' . ($this->bank_account_holder ?? $this->name),
'IBAN: ' . $this->iban,
__('Bank') . ': ' .$this->bank_name,
] : [])->shouldCache();
}

public function events(): HasMany
{
return $this->hasMany(Event::class);
Expand Down
25 changes: 25 additions & 0 deletions app/Models/Traits/HasFullName.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
<?php

namespace App\Models\Traits;

use Illuminate\Database\Eloquent\Casts\Attribute;

/**
* @property string $first_name
* @property string $last_name
*
* @property-read string greeting {@see self::greeting()}
* @property-read string $name {@see self::name()}
*/
trait HasFullName
{
public function greeting(): Attribute
{
return new Attribute(fn () => __('Hello :name,', ['name' => $this->name]));
}

public function name(): Attribute
{
return new Attribute(fn () => $this->first_name . ' ' . $this->last_name);
}
}
16 changes: 2 additions & 14 deletions app/Models/User.php
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
use App\Models\QueryBuilder\SortOptions;
use App\Models\Traits\FiltersByRelationExistence;
use App\Models\Traits\HasAddress;
use App\Models\Traits\HasFullName;
use App\Models\Traits\HasPhone;
use App\Models\Traits\Searchable;
use App\Notifications\AccountCreatedNotification;
Expand All @@ -17,7 +18,6 @@
use Carbon\Carbon;
use Illuminate\Contracts\Auth\MustVerifyEmail;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Casts\Attribute;
use Illuminate\Database\Eloquent\Collection;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
Expand Down Expand Up @@ -45,9 +45,6 @@
* @property ActiveStatus $status
* @property ?Carbon $last_login_at
*
* @property-read string greeting {@see User::greeting()}
* @property-read string $name {@see User::name()}
*
* @property-read Collection|Booking[] $bookings {@see self::bookings()}
* @property-read Collection|Event[] $responsibleForEvents {@see self::responsibleForEvents()}
* @property-read Collection|EventSeries[] $responsibleForEventSeries {@see self::responsibleForEventSeries()}
Expand All @@ -62,6 +59,7 @@ class User extends Authenticatable implements MustVerifyEmail
use HasAddress;
use HasApiTokens;
use HasFactory;
use HasFullName;
use HasPhone;
use Notifiable;
use Searchable;
Expand Down Expand Up @@ -107,16 +105,6 @@ class User extends Authenticatable implements MustVerifyEmail
'last_login_at' => 'datetime',
];

public function greeting(): Attribute
{
return new Attribute(fn () => __('Hello :name', ['name' => $this->name]));
}

public function name(): Attribute
{
return new Attribute(fn () => $this->first_name . ' ' . $this->last_name);
}

public function bookings(): HasMany
{
return $this->hasMany(Booking::class, 'booked_by_user_id')
Expand Down
27 changes: 10 additions & 17 deletions app/Notifications/BookingConfirmation.php
Original file line number Diff line number Diff line change
Expand Up @@ -22,19 +22,16 @@ public function __construct(private readonly Booking $booking)
*/
public function toMail($notifiable)
{
$mail = (new MailMessage())
->subject(config('app.name') . ': ' . __('Booking'))
$mail = $this->booking->prepareMailMessage()
->subject(
config('app.name')
. ': '
. __('Booking no. :id', [
'id' => $this->booking->id,
])
)
->line(__('we received your booking:'))
->line($this->booking->first_name . ' ' . $this->booking->last_name);

if (isset($this->booking->bookedByUser) && $this->booking->bookedByUser->email !== $this->booking->email) {
$mail->cc($this->booking->bookedByUser->email);
}

$organization = $this->booking->bookingOption->event->organization;
if (isset($organization->email)) {
$mail->replyTo($organization->email, $organization->name);
}
->line($this->booking->name);

if (isset($this->booking->street)) {
$mail->line($this->booking->street . ' ' . ($this->booking->house_number ?? ''));
Expand All @@ -48,11 +45,7 @@ public function toMail($notifiable)
'price' => formatDecimal($this->booking->price) . '',
'date' => formatDate($this->booking->payment_deadline),
]));
$mail->lines([
__('Account holder') . ': ' . ($organization->bank_account_holder ?? $organization->name),
'IBAN: ' . $organization->iban,
__('Bank') . ': ' .$organization->bank_name,
]);
$mail->lines($this->booking->bookingOption->event->organization->bank_account_lines);
}

if (isset($this->booking->bookingOption->confirmation_text)) {
Expand Down
42 changes: 42 additions & 0 deletions app/Notifications/PaymentReminderNotification.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
<?php

namespace App\Notifications;

use App\Models\Booking;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Notifications\Notification;

class PaymentReminderNotification extends Notification implements ShouldQueue
{
use Queueable;

public function __construct(private readonly Booking $booking)
{
}

public function toMail($notifiable)
{
return $this->booking->prepareMailMessage()
->subject(
config('app.name')
. ': '
. __('Pending payment for booking no. :id', [
'id' => $this->booking->id,
])
)
->line(__('we have received your booking for :name dated :date, but have not yet been able to confirm receipt of payment.', [
'name' => $this->booking->name,
'date' => formatDate($this->booking->booked_at),
]))
->line(__('Please transfer :price to the following bank account as soon as possible:', [
'price' => formatDecimal($this->booking->price) . '',
]))
->lines($this->booking->bookingOption->event->organization->bank_account_lines);
}

public function via($notifiable)
{
return ['mail'];
}
}
1 change: 1 addition & 0 deletions database/factories/BookingFactory.php
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ public function definition(): array
'phone' => fake()->phoneNumber(),
'email' => sprintf('%s.%s@%s', Str::slug($firstName), Str::slug($lastName), fake()->unique()->domainName()),
'date_of_birth' => fake()->date(),
'booked_at' => $this->faker->dateTime(),
];
}

Expand Down
6 changes: 4 additions & 2 deletions lang/de.json
Original file line number Diff line number Diff line change
Expand Up @@ -49,7 +49,6 @@
"Book": "Anmelden",
"Book with costs": "Kostenpflichtig anmelden",
"Booked by": "Angemeldet von",
"Booking": "Anmeldung",
"Booking date": "Anmeldedatum",
"Booking no. :id": "Anmeldung Nr. :id",
"Booking not completed yet": "Anmeldung noch nicht vollständig",
Expand Down Expand Up @@ -168,7 +167,7 @@
"Groups": "Gruppen",
"Guest": "Gast",
"headline": "Überschrift",
"Hello :name": "Hallo :name",
"Hello :name,": "Hallo :name,",
"Hello!": "Hallo!",
"hidden field": "verstecktes Feld",
"hide deleted": "gelöschte ausblenden",
Expand Down Expand Up @@ -243,10 +242,12 @@
"Payment status": "Zahlungsstatus",
"Payments": "Zahlungen",
"PDF": "PDF",
"Pending payment for booking no. :id": "Ausstehende Zahlung für Anmeldung Nr. :id",
"Period of the event": "Zeitraum der Veranstaltung",
"Personal access tokens": "Persönliche Zugangstoken",
"Phone number": "Telefonnummer",
"Please click the button below to verify your e-mail address.": "Bitte klicke auf den nachstehenden Button, um deine E-Mail-Adresse zu bestätigen.",
"Please transfer :price to the following bank account as soon as possible:": "Bitte überweise sobald wie möglich :price auf das folgende Bankkonto:",
"Please transfer :price to the following bank account by :date:": "Bitte überweise bis zum :date :price auf das folgende Bankkonto:",
"Position": "Position",
"Postal code": "Postleitzahl (PLZ)",
Expand Down Expand Up @@ -357,6 +358,7 @@
"View users": "Benutzer ansehen",
"Visibility": "Sichtbarkeit",
"waiting for approval": "wartend auf Freigabe",
"we have received your booking for :name dated :date, but have not yet been able to confirm receipt of payment.": "wir haben deine Anmeldung für :name vom :date erhalten, konnten aber bisher keinen Zahlungseingang feststellen.",
"we received your booking:": "wir haben deine Anmeldung erhalten:",
"We will send you a confirmation by e-mail shortly.": "Wir senden dir in Kürze eine Bestätigung per E-Mail zu.",
"Website": "Webauftritt",
Expand Down
Loading

0 comments on commit 94d4db9

Please sign in to comment.