From c53191a41bb42485f6f08e279bc23e528f3d451f Mon Sep 17 00:00:00 2001
From: isaacclad
Date: Mon, 8 Jun 2026 12:44:38 +0000
Subject: [PATCH] Notify transfer owners when files enter grace period.
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
---
.../ProcessTransferBillingCommand.php | 12 ++-
app/Models/Transfer.php | 17 ++++
app/Models/User.php | 8 ++
.../TransferOwnerGraceNotification.php | 89 ++++++++++++++++++
.../Transfer/TransferBillingService.php | 4 +-
.../Transfer/TransferOwnerMailService.php | 94 +++++++++++++++++++
app/Services/Transfer/TransferService.php | 1 +
config/transfer.php | 3 +
...add_owner_milestones_sent_to_transfers.php | 22 +++++
.../transfer-owner-grace.blade.php | 70 ++++++++++++++
.../transfer-recipient.blade.php | 5 +
tests/Feature/TransferBillingTest.php | 6 ++
tests/Feature/TransferOwnerGraceMailTest.php | 45 +++++++++
13 files changed, 372 insertions(+), 4 deletions(-)
create mode 100644 app/Notifications/TransferOwnerGraceNotification.php
create mode 100644 app/Services/Transfer/TransferOwnerMailService.php
create mode 100644 database/migrations/2026_06_09_150000_add_owner_milestones_sent_to_transfers.php
create mode 100644 resources/views/mail/notifications/transfer-owner-grace.blade.php
create mode 100644 tests/Feature/TransferOwnerGraceMailTest.php
diff --git a/app/Console/Commands/ProcessTransferBillingCommand.php b/app/Console/Commands/ProcessTransferBillingCommand.php
index 6a569f0..ff76222 100644
--- a/app/Console/Commands/ProcessTransferBillingCommand.php
+++ b/app/Console/Commands/ProcessTransferBillingCommand.php
@@ -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;
diff --git a/app/Models/Transfer.php b/app/Models/Transfer.php
index 70d2a1c..eaabaaf 100644
--- a/app/Models/Transfer.php
+++ b/app/Models/Transfer.php
@@ -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();
+ }
}
diff --git a/app/Models/User.php b/app/Models/User.php
index 5a2ce1c..2837d23 100644
--- a/app/Models/User.php
+++ b/app/Models/User.php
@@ -87,4 +87,12 @@ class User extends Authenticatable
return $url !== '' ? $url : null;
}
+
+ /** @return array|string */
+ public function routeNotificationForMail(mixed $notification): array|string
+ {
+ $notifyEmail = trim((string) ($this->qrSetting?->notify_email ?? ''));
+
+ return $notifyEmail !== '' ? $notifyEmail : (string) $this->email;
+ }
}
diff --git a/app/Notifications/TransferOwnerGraceNotification.php b/app/Notifications/TransferOwnerGraceNotification.php
new file mode 100644
index 0000000..e9f64f3
--- /dev/null
+++ b/app/Notifications/TransferOwnerGraceNotification.php
@@ -0,0 +1,89 @@
+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;
+ }
+}
diff --git a/app/Services/Transfer/TransferBillingService.php b/app/Services/Transfer/TransferBillingService.php
index 15b6952..56bd270 100644
--- a/app/Services/Transfer/TransferBillingService.php
+++ b/app/Services/Transfer/TransferBillingService.php
@@ -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
diff --git a/app/Services/Transfer/TransferOwnerMailService.php b/app/Services/Transfer/TransferOwnerMailService.php
new file mode 100644
index 0000000..c1f0d7b
--- /dev/null
+++ b/app/Services/Transfer/TransferOwnerMailService.php
@@ -0,0 +1,94 @@
+ */
+ 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 */
+ 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;
+ }
+}
diff --git a/app/Services/Transfer/TransferService.php b/app/Services/Transfer/TransferService.php
index 885c99f..fea9cdb 100644
--- a/app/Services/Transfer/TransferService.php
+++ b/app/Services/Transfer/TransferService.php
@@ -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,
diff --git a/config/transfer.php b/config/transfer.php
index 3086d99..15addc9 100644
--- a/config/transfer.php
+++ b/config/transfer.php
@@ -40,6 +40,9 @@ return [
'default_recipient_email_milestones' => ['created'],
+ // Owner emails when a transfer enters grace and before files are deleted.
+ 'owner_grace_email_milestones' => ['grace_entered', '7', '3', '1'],
+
// Legacy env keys (billing is monthly until cancelled by non-payment + grace).
'default_retention_days' => (int) env('TRANSFER_DEFAULT_RETENTION_DAYS', 30),
'mail_retention_days' => (int) env('TRANSFER_MAIL_RETENTION_DAYS', 30),
diff --git a/database/migrations/2026_06_09_150000_add_owner_milestones_sent_to_transfers.php b/database/migrations/2026_06_09_150000_add_owner_milestones_sent_to_transfers.php
new file mode 100644
index 0000000..504c29e
--- /dev/null
+++ b/database/migrations/2026_06_09_150000_add_owner_milestones_sent_to_transfers.php
@@ -0,0 +1,22 @@
+json('owner_milestones_sent')->nullable()->after('recipient_milestones_sent');
+ });
+ }
+
+ public function down(): void
+ {
+ Schema::table('transfers', function (Blueprint $table) {
+ $table->dropColumn('owner_milestones_sent');
+ });
+ }
+};
diff --git a/resources/views/mail/notifications/transfer-owner-grace.blade.php b/resources/views/mail/notifications/transfer-owner-grace.blade.php
new file mode 100644
index 0000000..2261c8f
--- /dev/null
+++ b/resources/views/mail/notifications/transfer-owner-grace.blade.php
@@ -0,0 +1,70 @@
+@extends('mail.notifications.layout')
+
+@section('content')
+ @php
+ $graceEnds = $transfer->grace_ends_at;
+ $monthlyCost = number_format($transfer->monthlyCostGhs(), 2);
+ @endphp
+
+ @if($milestone === 'grace_entered')
+
+
Payment required
+
{{ $transfer->title }}
+
+
+ Your transfer entered the grace period
+
+ We could not renew storage for {{ $transfer->title }} because your Ladill wallet balance was too low.
+ Your files are still available for now, but they will be deleted unless payment is received.
+
+ @else
+
+
Deletion reminder
+
+ @if($daysRemaining === 1)
+ Files deleted tomorrow
+ @else
+ {{ $daysRemaining }} days remaining
+ @endif
+
+
+
+
+ @if($daysRemaining === 1)
+ Your files will be deleted tomorrow
+ @else
+ {{ $daysRemaining }} days left to save your transfer
+ @endif
+
+
+ {{ $transfer->title }} is still in the grace period.
+ Top up your wallet so we can renew storage before the files are removed.
+
+ @endif
+
+
+
Transfer details
+
+
Monthly storage cost
+
GHS {{ $monthlyCost }}
+
+ @if($graceEnds)
+
+
Files kept until
+
{{ $graceEnds->format('F j, Y') }}
+
+ @endif
+
+
+
+
+
+ Or view this transfer in Ladill Transfer.
+
+@endsection
diff --git a/resources/views/mail/notifications/transfer-recipient.blade.php b/resources/views/mail/notifications/transfer-recipient.blade.php
index 59fac53..559b9bc 100644
--- a/resources/views/mail/notifications/transfer-recipient.blade.php
+++ b/resources/views/mail/notifications/transfer-recipient.blade.php
@@ -23,6 +23,11 @@
@endif
@elseif($milestone === 'grace')
+
+
Grace period
+
Download soon
+
+
Download before these files are removed
The transfer {{ $transfer->title }} is in a grace period.
diff --git a/tests/Feature/TransferBillingTest.php b/tests/Feature/TransferBillingTest.php
index af020ca..0bb63c7 100644
--- a/tests/Feature/TransferBillingTest.php
+++ b/tests/Feature/TransferBillingTest.php
@@ -4,9 +4,11 @@ namespace Tests\Feature;
use App\Models\Transfer;
use App\Models\User;
+use App\Notifications\TransferOwnerGraceNotification;
use App\Services\Transfer\TransferBillingService;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Http;
+use Illuminate\Support\Facades\Notification;
use Illuminate\Support\Str;
use Tests\Concerns\FakesTransferBilling;
use Tests\TestCase;
@@ -18,6 +20,8 @@ class TransferBillingTest extends TestCase
public function test_failed_renewal_enters_grace_period(): void
{
+ Notification::fake();
+
$user = User::create([
'public_id' => (string) Str::uuid(),
'name' => 'Grace User',
@@ -46,6 +50,8 @@ class TransferBillingTest extends TestCase
$this->assertNotNull($transfer->grace_ends_at);
$this->assertTrue($transfer->grace_ends_at->isFuture());
$this->assertTrue($transfer->isAccessible());
+
+ Notification::assertSentTo($user, TransferOwnerGraceNotification::class);
}
public function test_transfer_past_grace_period_is_deleted(): void
diff --git a/tests/Feature/TransferOwnerGraceMailTest.php b/tests/Feature/TransferOwnerGraceMailTest.php
new file mode 100644
index 0000000..3c9555c
--- /dev/null
+++ b/tests/Feature/TransferOwnerGraceMailTest.php
@@ -0,0 +1,45 @@
+ (string) Str::uuid(),
+ 'name' => 'Owner',
+ 'email' => 'owner+'.uniqid().'@example.com',
+ ]);
+
+ $transfer = Transfer::create([
+ 'user_id' => $user->id,
+ 'title' => 'Grace reminder',
+ 'retention_days' => 30,
+ 'paid_until' => now()->subDay(),
+ 'grace_ends_at' => now()->addDays(3)->startOfDay(),
+ 'status' => Transfer::STATUS_GRACE,
+ 'storage_bytes' => 1024,
+ 'owner_milestones_sent' => ['grace_entered'],
+ ]);
+
+ $service = app(TransferOwnerMailService::class);
+ $this->assertSame(1, $service->processGraceReminders());
+ $this->assertSame(0, $service->processGraceReminders());
+
+ Notification::assertSentTo($user, TransferOwnerGraceNotification::class, 1);
+ }
+}