Notify transfer owners when files enter grace period.
Deploy Ladill Transfer / deploy (push) Successful in 1m0s
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:
@@ -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;
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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('transfers', function (Blueprint $table) {
|
||||
$table->json('owner_milestones_sent')->nullable()->after('recipient_milestones_sent');
|
||||
});
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
Schema::table('transfers', function (Blueprint $table) {
|
||||
$table->dropColumn('owner_milestones_sent');
|
||||
});
|
||||
}
|
||||
};
|
||||
@@ -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')
|
||||
<div class="highlight-box highlight-box-warning">
|
||||
<p class="highlight-box-text">Payment required</p>
|
||||
<p class="highlight-box-subtext">{{ $transfer->title }}</p>
|
||||
</div>
|
||||
|
||||
<h1 class="email-title">Your transfer entered the grace period</h1>
|
||||
<p class="email-text">
|
||||
We could not renew storage for <strong>{{ $transfer->title }}</strong> because your Ladill wallet balance was too low.
|
||||
Your files are still available for now, but they will be deleted unless payment is received.
|
||||
</p>
|
||||
@else
|
||||
<div class="highlight-box highlight-box-warning">
|
||||
<p class="highlight-box-text">Deletion reminder</p>
|
||||
<p class="highlight-box-subtext">
|
||||
@if($daysRemaining === 1)
|
||||
Files deleted tomorrow
|
||||
@else
|
||||
{{ $daysRemaining }} days remaining
|
||||
@endif
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<h1 class="email-title">
|
||||
@if($daysRemaining === 1)
|
||||
Your files will be deleted tomorrow
|
||||
@else
|
||||
{{ $daysRemaining }} days left to save your transfer
|
||||
@endif
|
||||
</h1>
|
||||
<p class="email-text">
|
||||
<strong>{{ $transfer->title }}</strong> is still in the grace period.
|
||||
Top up your wallet so we can renew storage before the files are removed.
|
||||
</p>
|
||||
@endif
|
||||
|
||||
<div class="email-details">
|
||||
<p class="email-details-title">Transfer details</p>
|
||||
<div class="email-details-row">
|
||||
<p class="email-details-label">Monthly storage cost</p>
|
||||
<p class="email-details-value">GHS {{ $monthlyCost }}</p>
|
||||
</div>
|
||||
@if($graceEnds)
|
||||
<div class="email-details-row">
|
||||
<p class="email-details-label">Files kept until</p>
|
||||
<p class="email-details-value">{{ $graceEnds->format('F j, Y') }}</p>
|
||||
</div>
|
||||
@endif
|
||||
</div>
|
||||
|
||||
<table role="presentation" cellspacing="0" cellpadding="0" border="0" width="100%" style="margin:24px 0;">
|
||||
<tr>
|
||||
<td align="center">
|
||||
<a href="{{ $walletUrl }}" class="email-button">Top up wallet</a>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
|
||||
<p class="email-text" style="font-size:13px;color:#6b7280;text-align:center;">
|
||||
Or <a href="{{ $transferUrl }}" style="color:#4f46e5;">view this transfer</a> in Ladill Transfer.
|
||||
</p>
|
||||
@endsection
|
||||
@@ -23,6 +23,11 @@
|
||||
@endif
|
||||
</p>
|
||||
@elseif($milestone === 'grace')
|
||||
<div class="highlight-box highlight-box-warning" style="margin-top:0;">
|
||||
<p class="highlight-box-text">Grace period</p>
|
||||
<p class="highlight-box-subtext">Download soon</p>
|
||||
</div>
|
||||
|
||||
<h1 class="email-title">Download before these files are removed</h1>
|
||||
<p class="email-text">
|
||||
The transfer <strong>{{ $transfer->title }}</strong> is in a grace period.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
<?php
|
||||
|
||||
namespace Tests\Feature;
|
||||
|
||||
use App\Models\Transfer;
|
||||
use App\Models\User;
|
||||
use App\Notifications\TransferOwnerGraceNotification;
|
||||
use App\Services\Transfer\TransferOwnerMailService;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Illuminate\Support\Facades\Notification;
|
||||
use Illuminate\Support\Str;
|
||||
use Tests\TestCase;
|
||||
|
||||
class TransferOwnerGraceMailTest extends TestCase
|
||||
{
|
||||
use RefreshDatabase;
|
||||
|
||||
public function test_owner_grace_day_milestone_is_sent_once(): void
|
||||
{
|
||||
Notification::fake();
|
||||
|
||||
$user = User::create([
|
||||
'public_id' => (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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user