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

+ + @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 +

+ + @endif + + + + + + + +
+ +
+ + +@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