Notify transfer owners when files enter grace period.
Deploy Ladill Transfer / deploy (push) Successful in 1m0s

Send grace-entered and countdown emails to owners alongside recipient grace milestones so unpaid transfers get clear wallet top-up reminders before deletion.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
isaacclad
2026-06-08 12:44:38 +00:00
co-authored by Cursor
parent 7c4673adb6
commit c53191a41b
13 changed files with 372 additions and 4 deletions
@@ -3,6 +3,7 @@
namespace App\Console\Commands;
use App\Services\Transfer\TransferBillingService;
use App\Services\Transfer\TransferOwnerMailService;
use App\Services\Transfer\TransferRecipientMailService;
use Illuminate\Console\Command;
@@ -12,17 +13,22 @@ class ProcessTransferBillingCommand extends Command
protected $description = 'Renew monthly transfer storage billing and delete transfers past the grace period';
public function handle(TransferBillingService $billing, TransferRecipientMailService $recipients): int
{
public function handle(
TransferBillingService $billing,
TransferRecipientMailService $recipients,
TransferOwnerMailService $owners,
): int {
$result = $billing->processDueRenewals();
$recipientEmails = $recipients->processDueMilestones();
$ownerEmails = $owners->processGraceReminders();
$this->info(sprintf(
'Transfer billing: %d renewed, %d entered grace, %d deleted, %d recipient reminders sent.',
'Transfer billing: %d renewed, %d entered grace, %d deleted, %d recipient reminders, %d owner grace reminders.',
$result['renewed'],
$result['graced'],
$result['deleted'],
$recipientEmails,
$ownerEmails,
));
return self::SUCCESS;
+17
View File
@@ -26,6 +26,7 @@ class Transfer extends Model
'recipient_email',
'recipient_email_milestones',
'recipient_milestones_sent',
'owner_milestones_sent',
'password_hash',
'retention_days',
'expires_at',
@@ -45,6 +46,7 @@ class Transfer extends Model
'last_billed_at' => 'datetime',
'recipient_email_milestones' => 'array',
'recipient_milestones_sent' => 'array',
'owner_milestones_sent' => 'array',
'downloads_total' => 'integer',
'storage_bytes' => 'integer',
];
@@ -174,4 +176,19 @@ class Transfer extends Model
'recipient_milestones_sent' => array_values(array_unique($sent)),
])->save();
}
public function ownerMilestoneSent(string $milestone): bool
{
return in_array($milestone, (array) ($this->owner_milestones_sent ?? []), true);
}
public function markOwnerMilestoneSent(string $milestone): void
{
$sent = (array) ($this->owner_milestones_sent ?? []);
$sent[] = $milestone;
$this->forceFill([
'owner_milestones_sent' => array_values(array_unique($sent)),
])->save();
}
}
+8
View File
@@ -87,4 +87,12 @@ class User extends Authenticatable
return $url !== '' ? $url : null;
}
/** @return array<int, string>|string */
public function routeNotificationForMail(mixed $notification): array|string
{
$notifyEmail = trim((string) ($this->qrSetting?->notify_email ?? ''));
return $notifyEmail !== '' ? $notifyEmail : (string) $this->email;
}
}
@@ -0,0 +1,89 @@
<?php
namespace App\Notifications;
use App\Models\Transfer;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Notifications\Messages\MailMessage;
use Illuminate\Notifications\Notification;
class TransferOwnerGraceNotification extends Notification implements ShouldQueue
{
use Queueable;
public function __construct(
private readonly Transfer $transfer,
private readonly string $milestone,
) {}
public function via(mixed $notifiable): array
{
return ['mail', 'database'];
}
public function toMail(mixed $notifiable): MailMessage
{
$transfer = $this->transfer->loadMissing(['files', 'qrCode']);
return (new MailMessage())
->subject($this->subjectLine())
->view('mail.notifications.transfer-owner-grace', [
'transfer' => $transfer,
'milestone' => $this->milestone,
'daysRemaining' => $this->daysRemaining(),
'walletUrl' => 'https://'.config('app.account_domain', 'account.ladill.com').'/wallet',
'transferUrl' => route('transfer.transfers.show', $transfer),
]);
}
public function toArray(mixed $notifiable): array
{
return [
'title' => $this->headline(),
'message' => $this->inAppMessage(),
'icon' => 'transfer',
'url' => route('transfer.transfers.show', $this->transfer),
];
}
private function subjectLine(): string
{
return match ($this->milestone) {
'grace_entered' => "Payment needed: {$this->transfer->title} entered grace period",
'1' => "Last day to save {$this->transfer->title} from deletion",
'3' => "3 days left to save {$this->transfer->title}",
'7' => "Reminder: {$this->transfer->title} will be deleted soon",
default => "Transfer payment reminder: {$this->transfer->title}",
};
}
private function headline(): string
{
return match ($this->milestone) {
'grace_entered' => 'Transfer entered grace period',
'1' => 'Files deleted tomorrow',
default => 'Transfer deletion reminder',
};
}
private function inAppMessage(): string
{
$title = $this->transfer->title;
return match ($this->milestone) {
'grace_entered' => "{$title} could not be renewed. Top up your wallet within the grace period to keep the files.",
'1' => "{$title} will be deleted tomorrow unless your wallet is topped up.",
default => "{$title} will be deleted in {$this->daysRemaining()} days unless payment is received.",
};
}
private function daysRemaining(): ?int
{
if ($this->milestone === 'grace_entered') {
return null;
}
return is_numeric($this->milestone) ? (int) $this->milestone : null;
}
}
@@ -103,7 +103,9 @@ class TransferBillingService
'expires_at' => $graceEndsAt,
]);
app(TransferRecipientMailService::class)->notifyRecipient($transfer->fresh(), 'grace');
$transfer = $transfer->fresh(['user', 'files', 'qrCode']);
app(TransferRecipientMailService::class)->notifyRecipient($transfer, 'grace');
app(TransferOwnerMailService::class)->notifyGraceEntered($transfer);
}
public function processDueRenewals(): array
@@ -0,0 +1,94 @@
<?php
namespace App\Services\Transfer;
use App\Models\Transfer;
use App\Notifications\TransferOwnerGraceNotification;
use Carbon\CarbonInterface;
/** Owner emails when transfers enter grace and before files are deleted. */
class TransferOwnerMailService
{
/** @return list<string> */
public function graceMilestones(): array
{
return array_values(array_map(
'strval',
(array) config('transfer.owner_grace_email_milestones', ['grace_entered', '7', '3', '1']),
));
}
public function notifyGraceEntered(Transfer $transfer): bool
{
return $this->notifyOwner($transfer, 'grace_entered');
}
public function processGraceReminders(): int
{
$sent = 0;
Transfer::query()
->where('status', Transfer::STATUS_GRACE)
->whereNotNull('grace_ends_at')
->where('grace_ends_at', '>', now())
->with('user')
->chunkById(100, function ($transfers) use (&$sent) {
foreach ($transfers as $transfer) {
foreach ($this->dueGraceDayMilestones($transfer) as $milestone) {
if ($this->notifyOwner($transfer, $milestone)) {
$sent++;
}
}
}
});
return $sent;
}
/** @return list<string> */
private function dueGraceDayMilestones(Transfer $transfer): array
{
if (! $transfer->grace_ends_at instanceof CarbonInterface) {
return [];
}
$daysRemaining = (int) now()->startOfDay()->diffInDays(
$transfer->grace_ends_at->copy()->startOfDay(),
false,
);
$due = [];
foreach (['7', '3', '1'] as $milestone) {
if ($daysRemaining === (int) $milestone && in_array($milestone, $this->graceMilestones(), true)) {
$due[] = $milestone;
}
}
return $due;
}
private function notifyOwner(Transfer $transfer, string $milestone): bool
{
if (! in_array($milestone, $this->graceMilestones(), true)) {
return false;
}
if ($transfer->ownerMilestoneSent($milestone)) {
return false;
}
$user = $transfer->user;
if ($user === null) {
return false;
}
$user->notify(new TransferOwnerGraceNotification(
$transfer->loadMissing(['files', 'qrCode']),
$milestone,
));
$transfer->markOwnerMilestoneSent($milestone);
return true;
}
}
@@ -65,6 +65,7 @@ class TransferService
'recipient_email' => $recipientEmail !== '' ? $recipientEmail : null,
'recipient_email_milestones' => $recipientEmail !== '' ? $recipientMilestones : null,
'recipient_milestones_sent' => [],
'owner_milestones_sent' => [],
'password_hash' => $passwordHash,
'retention_days' => $billingPeriodDays,
'status' => Transfer::STATUS_ACTIVE,