Bill transfer storage monthly with a 15-day grace period before deletion.
Deploy Ladill Transfer / deploy (push) Successful in 51s

Files stay available while wallet renewals succeed each month. Failed renewals
keep files for TRANSFER_GRACE_PERIOD_DAYS (default 15) before automatic cleanup.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
isaacclad
2026-06-08 12:28:33 +00:00
co-authored by Cursor
parent 65b634bb0b
commit 311b5b40bb
23 changed files with 502 additions and 56 deletions
+2
View File
@@ -38,6 +38,8 @@ IDENTITY_API_URL=https://ladill.com/api
IDENTITY_API_KEY_TRANSFER= IDENTITY_API_KEY_TRANSFER=
TRANSFER_PRICE_PER_GB_MONTH=0.30 TRANSFER_PRICE_PER_GB_MONTH=0.30
TRANSFER_GRACE_PERIOD_DAYS=15
TRANSFER_BILLING_PERIOD_DAYS=30
# 0 = no app-level file size cap (chunked uploads handle large files). # 0 = no app-level file size cap (chunked uploads handle large files).
TRANSFER_MAX_FILE_BYTES=0 TRANSFER_MAX_FILE_BYTES=0
TRANSFER_MAX_FILES=20 TRANSFER_MAX_FILES=20
@@ -0,0 +1,27 @@
<?php
namespace App\Console\Commands;
use App\Services\Transfer\TransferBillingService;
use Illuminate\Console\Command;
class ProcessTransferBillingCommand extends Command
{
protected $signature = 'transfer:process-billing';
protected $description = 'Renew monthly transfer storage billing and delete transfers past the grace period';
public function handle(TransferBillingService $billing): int
{
$result = $billing->processDueRenewals();
$this->info(sprintf(
'Transfer billing: %d renewed, %d entered grace, %d deleted.',
$result['renewed'],
$result['graced'],
$result['deleted'],
));
return self::SUCCESS;
}
}
@@ -140,7 +140,8 @@ class ChunkedUploadController extends Controller
'id' => $transfer->id, 'id' => $transfer->id,
'title' => $transfer->title, 'title' => $transfer->title,
'public_url' => $transfer->qrCode?->publicUrl(), 'public_url' => $transfer->qrCode?->publicUrl(),
'expires_at' => $transfer->expires_at?->toIso8601String(), 'paid_until' => $transfer->paid_until?->toIso8601String(),
'expires_at' => $transfer->paid_until?->toIso8601String(),
'files' => $transfer->files->map(fn ($file) => [ 'files' => $transfer->files->map(fn ($file) => [
'name' => $file->original_name, 'name' => $file->original_name,
'size_bytes' => $file->size_bytes, 'size_bytes' => $file->size_bytes,
@@ -69,7 +69,8 @@ class TransferController extends Controller
'id' => $transfer->id, 'id' => $transfer->id,
'title' => $transfer->title, 'title' => $transfer->title,
'public_url' => $transfer->qrCode?->publicUrl(), 'public_url' => $transfer->qrCode?->publicUrl(),
'expires_at' => $transfer->expires_at?->toIso8601String(), 'paid_until' => $transfer->paid_until?->toIso8601String(),
'expires_at' => $transfer->paid_until?->toIso8601String(),
'files' => $transfer->files->map(fn ($file) => [ 'files' => $transfer->files->map(fn ($file) => [
'name' => $file->original_name, 'name' => $file->original_name,
'size_bytes' => $file->size_bytes, 'size_bytes' => $file->size_bytes,
@@ -52,10 +52,7 @@ class AccountController extends Controller
$activeTransfers = Transfer::query() $activeTransfers = Transfer::query()
->where('user_id', $user->id) ->where('user_id', $user->id)
->where('status', Transfer::STATUS_ACTIVE) ->accessible()
->where(function ($query) {
$query->whereNull('expires_at')->orWhere('expires_at', '>', now());
})
->orderByDesc('storage_bytes') ->orderByDesc('storage_bytes')
->get(); ->get();
@@ -21,7 +21,7 @@ class OverviewController extends Controller
$transfers = Transfer::query() $transfers = Transfer::query()
->where('user_id', $account->id) ->where('user_id', $account->id)
->where('status', Transfer::STATUS_ACTIVE); ->accessible();
$activeCount = (clone $transfers)->count(); $activeCount = (clone $transfers)->count();
$storageBytes = (int) (clone $transfers)->sum('storage_bytes'); $storageBytes = (int) (clone $transfers)->sum('storage_bytes');
@@ -37,14 +37,14 @@ class OverviewController extends Controller
$expiringSoon = Transfer::query() $expiringSoon = Transfer::query()
->where('user_id', $account->id) ->where('user_id', $account->id)
->where('status', Transfer::STATUS_ACTIVE) ->accessible()
->whereNotNull('expires_at') ->whereNotNull('paid_until')
->whereBetween('expires_at', [now(), now()->addDays(7)]) ->whereBetween('paid_until', [now(), now()->addDays(7)])
->count(); ->count();
$recentTransfers = Transfer::query() $recentTransfers = Transfer::query()
->where('user_id', $account->id) ->where('user_id', $account->id)
->where('status', Transfer::STATUS_ACTIVE) ->accessible()
->with('qrCode') ->with('qrCode')
->latest() ->latest()
->limit(6) ->limit(6)
@@ -31,7 +31,7 @@ class TransferController extends Controller
$transfers = Transfer::query() $transfers = Transfer::query()
->where('user_id', $account->id) ->where('user_id', $account->id)
->where('status', Transfer::STATUS_ACTIVE) ->accessible()
->with(['qrCode', 'files']) ->with(['qrCode', 'files'])
->latest() ->latest()
->paginate(20); ->paginate(20);
@@ -42,9 +42,9 @@ class TransferController extends Controller
public function create(): View public function create(): View
{ {
return view('transfer.transfers.create', [ return view('transfer.transfers.create', [
'defaultRetentionDays' => (int) config('transfer.default_retention_days', 30),
'maxFiles' => (int) config('transfer.max_files_per_transfer', 20), 'maxFiles' => (int) config('transfer.max_files_per_transfer', 20),
'pricePerGb' => (float) config('transfer.price_per_gb_month', 0.30), 'pricePerGb' => (float) config('transfer.price_per_gb_month', 0.30),
'gracePeriodDays' => (int) config('transfer.grace_period_days', 15),
]); ]);
} }
@@ -62,7 +62,6 @@ class TransferController extends Controller
'title' => 'required|string|max:120', 'title' => 'required|string|max:120',
'message' => 'nullable|string|max:2000', 'message' => 'nullable|string|max:2000',
'password' => 'nullable|string|min:4|max:64', 'password' => 'nullable|string|min:4|max:64',
'retention_days' => 'nullable|integer|min:1|max:365',
'files' => 'nullable|array', 'files' => 'nullable|array',
'files.*' => $fileRules, 'files.*' => $fileRules,
'upload_ids' => 'nullable|array', 'upload_ids' => 'nullable|array',
+71 -3
View File
@@ -2,6 +2,8 @@
namespace App\Models; namespace App\Models;
use Carbon\CarbonInterface;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo; use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\HasMany; use Illuminate\Database\Eloquent\Relations\HasMany;
@@ -10,6 +12,8 @@ class Transfer extends Model
{ {
public const STATUS_ACTIVE = 'active'; public const STATUS_ACTIVE = 'active';
public const STATUS_GRACE = 'grace';
public const STATUS_EXPIRED = 'expired'; public const STATUS_EXPIRED = 'expired';
public const STATUS_DELETED = 'deleted'; public const STATUS_DELETED = 'deleted';
@@ -22,6 +26,9 @@ class Transfer extends Model
'password_hash', 'password_hash',
'retention_days', 'retention_days',
'expires_at', 'expires_at',
'paid_until',
'grace_ends_at',
'last_billed_at',
'status', 'status',
'downloads_total', 'downloads_total',
'storage_bytes', 'storage_bytes',
@@ -30,6 +37,9 @@ class Transfer extends Model
protected $casts = [ protected $casts = [
'retention_days' => 'integer', 'retention_days' => 'integer',
'expires_at' => 'datetime', 'expires_at' => 'datetime',
'paid_until' => 'datetime',
'grace_ends_at' => 'datetime',
'last_billed_at' => 'datetime',
'downloads_total' => 'integer', 'downloads_total' => 'integer',
'storage_bytes' => 'integer', 'storage_bytes' => 'integer',
]; ];
@@ -54,17 +64,52 @@ class Transfer extends Model
return $this->hasMany(TransferDownloadEvent::class); return $this->hasMany(TransferDownloadEvent::class);
} }
/** Files are available to recipients while paid or within the unpaid grace window. */
public function isAccessible(): bool
{
if ($this->status === self::STATUS_DELETED) {
return false;
}
if ($this->isPaid()) {
return true;
}
return $this->isInGracePeriod();
}
/** @deprecated Use isAccessible() — kept for existing call sites. */
public function isActive(): bool public function isActive(): bool
{ {
if ($this->status !== self::STATUS_ACTIVE) { return $this->isAccessible();
}
public function isPaid(): bool
{
if (! in_array($this->status, [self::STATUS_ACTIVE, self::STATUS_GRACE], true)) {
return false; return false;
} }
if ($this->expires_at && $this->expires_at->isPast()) { return $this->paid_until instanceof CarbonInterface && $this->paid_until->isFuture();
}
public function isInGracePeriod(): bool
{
if ($this->status !== self::STATUS_GRACE) {
return false; return false;
} }
return true; return $this->grace_ends_at instanceof CarbonInterface && $this->grace_ends_at->isFuture();
}
public function isPastGracePeriod(): bool
{
return $this->grace_ends_at instanceof CarbonInterface && $this->grace_ends_at->isPast();
}
public function billingPeriodEndsAt(): ?CarbonInterface
{
return $this->paid_until;
} }
public function isPasswordProtected(): bool public function isPasswordProtected(): bool
@@ -81,4 +126,27 @@ class Transfer extends Model
{ {
return round($this->storageGb() * (float) config('transfer.price_per_gb_month', 0.30), 2); return round($this->storageGb() * (float) config('transfer.price_per_gb_month', 0.30), 2);
} }
public function billingStatusLabel(): string
{
if ($this->isPaid()) {
return 'Active';
}
if ($this->isInGracePeriod()) {
return 'Payment due';
}
return 'Unavailable';
}
public function scopeAccessible(Builder $query): Builder
{
return $query
->whereIn('status', [self::STATUS_ACTIVE, self::STATUS_GRACE])
->where(function (Builder $inner) {
$inner->where('paid_until', '>', now())
->orWhere('grace_ends_at', '>', now());
});
}
} }
+3 -2
View File
@@ -115,8 +115,9 @@ class AfiaService
- Settings: notification preferences for transfer activity. - Settings: notification preferences for transfer activity.
Pricing: Pricing:
- Storage retention is prepaid at GHS {$pricePerGb} per GB per month (metered on actual storage; no free tier). - Storage is billed monthly at GHS {$pricePerGb} per GB (metered on actual storage; no free tier).
- Wallet must have funds to keep transfers active through their retention period. - Transfers stay active while the wallet can pay each monthly renewal.
- If a renewal fails, files are kept for the grace period (default 15 days) before automatic deletion.
Rules: Rules:
- Only answer questions about Ladill Transfer uploads, share links, QR codes, passwords, retention/expiry, downloads, storage billing, team, and wallet. - Only answer questions about Ladill Transfer uploads, share links, QR codes, passwords, retention/expiry, downloads, storage billing, team, and wallet.
@@ -0,0 +1,166 @@
<?php
namespace App\Services\Transfer;
use App\Models\Transfer;
use App\Models\User;
use App\Services\Billing\BillingClient;
use Carbon\CarbonInterface;
use Illuminate\Support\Carbon;
use RuntimeException;
/** Monthly storage billing: paid while wallet debits succeed; 15-day grace then deletion. */
class TransferBillingService
{
public function __construct(
private BillingClient $billing,
private TransferService $transfers,
) {}
public function gracePeriodDays(): int
{
return max(1, (int) config('transfer.grace_period_days', 15));
}
public function amountMinorFor(Transfer $transfer): int
{
$ghs = $transfer->monthlyCostGhs();
$minor = (int) round($ghs * 100);
return max($minor, 1);
}
/** Charge the first monthly period when a transfer is created. */
public function chargeInitialPeriod(Transfer $transfer, User $user): void
{
$amountMinor = $this->amountMinorFor($transfer);
if (! $this->billing->canAfford($user->public_id, $amountMinor)) {
throw new RuntimeException('Insufficient wallet balance for the first month of storage. Top up your Ladill wallet and try again.');
}
$periodEnd = $this->nextPeriodEnd(now());
$reference = $this->billingReference($transfer, $periodEnd);
if (! $this->billing->debit(
$user->public_id,
$amountMinor,
(string) config('billing.service', 'transfer'),
'transfer_storage',
$reference,
$transfer->id,
sprintf('Transfer storage: %s (%s)', $transfer->title, $transfer->storageGb().' GB'),
)) {
throw new RuntimeException('Could not debit your wallet for storage. Top up and try again.');
}
$this->markPaidThrough($transfer, $periodEnd);
}
/** Attempt to renew a transfer for another month. */
public function chargeRenewal(Transfer $transfer): bool
{
$transfer->loadMissing('user');
$user = $transfer->user;
if ($user === null || $user->public_id === null) {
return false;
}
$amountMinor = $this->amountMinorFor($transfer);
$base = $transfer->paid_until instanceof CarbonInterface
? $transfer->paid_until->copy()
: now();
$periodEnd = $this->nextPeriodEnd($base);
$reference = $this->billingReference($transfer, $periodEnd);
if (! $this->billing->canAfford($user->public_id, $amountMinor)) {
return false;
}
if (! $this->billing->debit(
$user->public_id,
$amountMinor,
(string) config('billing.service', 'transfer'),
'transfer_storage_renewal',
$reference,
$transfer->id,
sprintf('Transfer storage renewal: %s', $transfer->title),
)) {
return false;
}
$this->markPaidThrough($transfer, $periodEnd);
return true;
}
public function enterGracePeriod(Transfer $transfer): void
{
$graceEndsAt = now()->addDays($this->gracePeriodDays());
$transfer->update([
'status' => Transfer::STATUS_GRACE,
'grace_ends_at' => $graceEndsAt,
'expires_at' => $graceEndsAt,
]);
}
public function processDueRenewals(): array
{
$renewed = 0;
$graced = 0;
$deleted = 0;
$due = Transfer::query()
->whereIn('status', [Transfer::STATUS_ACTIVE, Transfer::STATUS_GRACE])
->whereNotNull('paid_until')
->where('paid_until', '<=', now())
->with('user')
->get();
foreach ($due as $transfer) {
if ($this->chargeRenewal($transfer)) {
$renewed++;
continue;
}
if ($transfer->status !== Transfer::STATUS_GRACE) {
$this->enterGracePeriod($transfer);
$graced++;
}
}
$expired = Transfer::query()
->where('status', Transfer::STATUS_GRACE)
->whereNotNull('grace_ends_at')
->where('grace_ends_at', '<=', now())
->get();
foreach ($expired as $transfer) {
$this->transfers->delete($transfer);
$deleted++;
}
return compact('renewed', 'graced', 'deleted');
}
private function markPaidThrough(Transfer $transfer, CarbonInterface $periodEnd): void
{
$transfer->update([
'status' => Transfer::STATUS_ACTIVE,
'paid_until' => $periodEnd,
'expires_at' => $periodEnd,
'grace_ends_at' => null,
'last_billed_at' => now(),
]);
}
private function nextPeriodEnd(CarbonInterface $from): CarbonInterface
{
return Carbon::instance($from)->addMonthNoOverflow();
}
private function billingReference(Transfer $transfer, CarbonInterface $periodEnd): string
{
return sprintf('transfer:%d:through:%s', $transfer->id, $periodEnd->format('Y-m-d'));
}
}
+6 -5
View File
@@ -39,10 +39,9 @@ class TransferService
} }
$maxBytes = (int) config('transfer.max_file_bytes', 0); $maxBytes = (int) config('transfer.max_file_bytes', 0);
$retentionDays = (int) ($data['retention_days'] ?? config('transfer.default_retention_days', 30)); $billingPeriodDays = (int) config('transfer.billing_period_days', 30);
$retentionDays = max(1, min(365, $retentionDays));
return DB::transaction(function () use ($user, $data, $files, $maxBytes, $retentionDays) { return DB::transaction(function () use ($user, $data, $files, $maxBytes, $billingPeriodDays) {
$passwordHash = null; $passwordHash = null;
if (! empty($data['password'])) { if (! empty($data['password'])) {
$passwordHash = Hash::make((string) $data['password']); $passwordHash = Hash::make((string) $data['password']);
@@ -53,8 +52,7 @@ class TransferService
'title' => trim((string) $data['title']), 'title' => trim((string) $data['title']),
'message' => isset($data['message']) ? trim((string) $data['message']) : null, 'message' => isset($data['message']) ? trim((string) $data['message']) : null,
'password_hash' => $passwordHash, 'password_hash' => $passwordHash,
'retention_days' => $retentionDays, 'retention_days' => $billingPeriodDays,
'expires_at' => now()->addDays($retentionDays),
'status' => Transfer::STATUS_ACTIVE, 'status' => Transfer::STATUS_ACTIVE,
]); ]);
@@ -82,6 +80,9 @@ class TransferService
'storage_bytes' => $totalBytes, 'storage_bytes' => $totalBytes,
]); ]);
$transfer = $transfer->fresh(['files', 'qrCode']);
app(TransferBillingService::class)->chargeInitialPeriod($transfer, $user);
return $transfer->fresh(['files', 'qrCode']); return $transfer->fresh(['files', 'qrCode']);
}); });
} }
+7 -3
View File
@@ -23,9 +23,13 @@ return [
// Max files per transfer. // Max files per transfer.
'max_files_per_transfer' => (int) env('TRANSFER_MAX_FILES', 20), 'max_files_per_transfer' => (int) env('TRANSFER_MAX_FILES', 20),
// Default retention when not specified (days). // Monthly billing cycle length (days) — storage is renewed each period while the wallet can pay.
'default_retention_days' => (int) env('TRANSFER_DEFAULT_RETENTION_DAYS', 30), 'billing_period_days' => (int) env('TRANSFER_BILLING_PERIOD_DAYS', 30),
// Retention for files uploaded via Ladill Mail (large attachments). // Days to keep files after a failed renewal before automatic deletion.
'grace_period_days' => (int) env('TRANSFER_GRACE_PERIOD_DAYS', 15),
// 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), 'mail_retention_days' => (int) env('TRANSFER_MAIL_RETENTION_DAYS', 30),
]; ];
@@ -0,0 +1,35 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::table('transfers', function (Blueprint $table) {
$table->timestamp('paid_until')->nullable()->after('expires_at');
$table->timestamp('grace_ends_at')->nullable()->after('paid_until');
$table->timestamp('last_billed_at')->nullable()->after('grace_ends_at');
$table->index(['status', 'paid_until']);
$table->index(['status', 'grace_ends_at']);
});
DB::table('transfers')
->whereNull('paid_until')
->whereNotNull('expires_at')
->update(['paid_until' => DB::raw('expires_at')]);
}
public function down(): void
{
Schema::table('transfers', function (Blueprint $table) {
$table->dropIndex(['status', 'paid_until']);
$table->dropIndex(['status', 'grace_ends_at']);
$table->dropColumn(['paid_until', 'grace_ends_at', 'last_billed_at']);
});
}
};
+2
View File
@@ -40,6 +40,8 @@ IDENTITY_API_KEY_TRANSFER=
TRANSFER_PRICE_PER_GB_MONTH=0.30 TRANSFER_PRICE_PER_GB_MONTH=0.30
TRANSFER_MAX_FILE_BYTES=0 TRANSFER_MAX_FILE_BYTES=0
TRANSFER_MAX_FILES=20 TRANSFER_MAX_FILES=20
TRANSFER_BILLING_PERIOD_DAYS=30
TRANSFER_GRACE_PERIOD_DAYS=15
TRANSFER_DEFAULT_RETENTION_DAYS=30 TRANSFER_DEFAULT_RETENTION_DAYS=30
TRANSFER_MAIL_RETENTION_DAYS=30 TRANSFER_MAIL_RETENTION_DAYS=30
+6 -4
View File
@@ -15,7 +15,7 @@
@endphp @endphp
<div class="mx-auto max-w-3xl"> <div class="mx-auto max-w-3xl">
<h1 class="text-xl font-semibold tracking-tight text-slate-900">Billing</h1> <h1 class="text-xl font-semibold tracking-tight text-slate-900">Billing</h1>
<p class="mt-0.5 text-sm text-slate-500">Storage is metered at GHS {{ number_format($pricePerGb, 2) }} per GB per month of retention and debited from your <a href="{{ $walletUrl }}" class="font-medium text-indigo-600 hover:text-indigo-800">Ladill wallet</a>.</p> <p class="mt-0.5 text-sm text-slate-500">Storage is billed monthly at GHS {{ number_format($pricePerGb, 2) }} per GB while each transfer stays active. Renewals debit your <a href="{{ $walletUrl }}" class="font-medium text-indigo-600 hover:text-indigo-800">Ladill wallet</a>; unpaid transfers are kept for {{ (int) config('transfer.grace_period_days', 15) }} days before deletion.</p>
<div class="mt-6 grid gap-4 sm:grid-cols-2 lg:grid-cols-4"> <div class="mt-6 grid gap-4 sm:grid-cols-2 lg:grid-cols-4">
<div class="rounded-2xl border border-slate-200 bg-white p-5"> <div class="rounded-2xl border border-slate-200 bg-white p-5">
@@ -51,8 +51,10 @@
<a href="{{ route('transfer.transfers.show', $transfer) }}" class="truncate text-sm font-medium text-slate-900 hover:text-indigo-700">{{ $transfer->title }}</a> <a href="{{ route('transfer.transfers.show', $transfer) }}" class="truncate text-sm font-medium text-slate-900 hover:text-indigo-700">{{ $transfer->title }}</a>
<p class="text-xs text-slate-400"> <p class="text-xs text-slate-400">
{{ $fmtBytes($transfer->storage_bytes) }} {{ $fmtBytes($transfer->storage_bytes) }}
@if($transfer->expires_at) @if($transfer->isInGracePeriod())
· expires {{ $transfer->expires_at->format('M j, Y') }} · payment due · kept until {{ $transfer->grace_ends_at?->format('M j, Y') }}
@elseif($transfer->paid_until)
· paid through {{ $transfer->paid_until->format('M j, Y') }}
@endif @endif
</p> </p>
</div> </div>
@@ -78,6 +80,6 @@
</div> </div>
</div> </div>
<p class="mt-4 text-xs text-slate-400">Retention is prepaid from your wallet. Keep it funded so transfers stay available through their expiry date.</p> <p class="mt-4 text-xs text-slate-400">Keep your wallet funded so monthly renewals succeed. If a renewal fails, you have {{ (int) config('transfer.grace_period_days', 15) }} days to top up before files are deleted.</p>
</div> </div>
</x-user-layout> </x-user-layout>
+1 -1
View File
@@ -58,7 +58,7 @@
<div class="rounded-2xl border border-indigo-100 bg-indigo-50/60 p-6"> <div class="rounded-2xl border border-indigo-100 bg-indigo-50/60 p-6">
<p class="text-xs font-medium uppercase tracking-wide text-indigo-600">Wallet balance</p> <p class="text-xs font-medium uppercase tracking-wide text-indigo-600">Wallet balance</p>
<p class="mt-1.5 text-2xl font-semibold text-slate-900">{{ $fmt($balanceMinor) }}</p> <p class="mt-1.5 text-2xl font-semibold text-slate-900">{{ $fmt($balanceMinor) }}</p>
<p class="mt-2 text-sm text-slate-600">Storage is billed at GHS {{ number_format((float) config('transfer.price_per_gb_month', 0.30), 2) }} per GB per month from your Ladill wallet.</p> <p class="mt-2 text-sm text-slate-600">Storage is billed monthly at GHS {{ number_format((float) config('transfer.price_per_gb_month', 0.30), 2) }} per GB while your wallet can pay. Unpaid renewals keep files for {{ (int) config('transfer.grace_period_days', 15) }} days before deletion.</p>
<div class="mt-4 space-y-3"> <div class="mt-4 space-y-3">
<a href="{{ route('transfer.transfers.create') }}" class="flex items-center justify-between rounded-xl border border-white bg-white px-4 py-3 text-sm hover:bg-indigo-50/50"> <a href="{{ route('transfer.transfers.create') }}" class="flex items-center justify-between rounded-xl border border-white bg-white px-4 py-3 text-sm hover:bg-indigo-50/50">
<span class="font-medium text-slate-700">New transfer</span> <span class="font-medium text-slate-700">New transfer</span>
@@ -4,7 +4,7 @@
<div> <div>
<a href="{{ route('transfer.transfers.index') }}" class="text-sm text-slate-500 hover:text-slate-700"> All transfers</a> <a href="{{ route('transfer.transfers.index') }}" class="text-sm text-slate-500 hover:text-slate-700"> All transfers</a>
<h1 class="mt-2 text-xl font-semibold text-slate-900">New transfer</h1> <h1 class="mt-2 text-xl font-semibold text-slate-900">New transfer</h1>
<p class="mt-1 text-sm text-slate-500">Upload files, set how long they stay hosted, and get a share link + QR code.</p> <p class="mt-1 text-sm text-slate-500">Upload files and get a share link + QR code. Storage is billed monthly from your Ladill wallet while the transfer stays active.</p>
</div> </div>
@if(session('error')) @if(session('error'))
@@ -51,19 +51,16 @@
@error('files.*')<p class="mt-1 text-xs text-red-600">{{ $message }}</p>@enderror @error('files.*')<p class="mt-1 text-xs text-red-600">{{ $message }}</p>@enderror
</div> </div>
<div class="grid gap-4 sm:grid-cols-2"> <div class="rounded-xl border border-indigo-100 bg-indigo-50/60 px-4 py-3 text-sm text-slate-600">
<div> <p class="font-medium text-slate-900">Monthly storage billing</p>
<label for="retention_days" class="block text-sm font-medium text-slate-700">Retention (days)</label> <p class="mt-1">GHS {{ number_format($pricePerGb, 2) }} per GB per month, debited from your wallet. Files stay available while payments succeed. If a monthly renewal fails, files are kept for {{ $gracePeriodDays }} days before deletion.</p>
<input type="number" name="retention_days" id="retention_days" value="{{ old('retention_days', $defaultRetentionDays) }}" min="1" max="365" </div>
class="mt-1 block w-full rounded-xl border-slate-200 text-sm shadow-sm focus:border-indigo-500 focus:ring-indigo-500">
<p class="mt-1 text-xs text-slate-500">GHS {{ number_format($pricePerGb, 2) }}/GB/month while files are stored.</p> <div>
</div> <label for="password" class="block text-sm font-medium text-slate-700">Password <span class="font-normal text-slate-400">(optional)</span></label>
<div> <input type="password" name="password" id="password" minlength="4" maxlength="64"
<label for="password" class="block text-sm font-medium text-slate-700">Password <span class="font-normal text-slate-400">(optional)</span></label> class="mt-1 block w-full rounded-xl border-slate-200 text-sm shadow-sm focus:border-indigo-500 focus:ring-indigo-500"
<input type="password" name="password" id="password" minlength="4" maxlength="64" autocomplete="new-password">
class="mt-1 block w-full rounded-xl border-slate-200 text-sm shadow-sm focus:border-indigo-500 focus:ring-indigo-500"
autocomplete="new-password">
</div>
</div> </div>
<div class="flex justify-end gap-3 pt-2"> <div class="flex justify-end gap-3 pt-2">
@@ -13,7 +13,16 @@
<div> <div>
<a href="{{ route('transfer.transfers.index') }}" class="text-sm text-slate-500 hover:text-slate-700"> All transfers</a> <a href="{{ route('transfer.transfers.index') }}" class="text-sm text-slate-500 hover:text-slate-700"> All transfers</a>
<h1 class="mt-2 text-xl font-semibold text-slate-900">{{ $transfer->title }}</h1> <h1 class="mt-2 text-xl font-semibold text-slate-900">{{ $transfer->title }}</h1>
<p class="mt-1 text-sm text-slate-500">Expires {{ $transfer->expires_at?->format('M j, Y') ?? '—' }} · {{ $fmtBytes($transfer->storage_bytes) }} stored</p> <p class="mt-1 text-sm text-slate-500">
@if($transfer->isInGracePeriod())
<span class="font-medium text-amber-700">Payment due</span> · files kept until {{ $transfer->grace_ends_at?->format('M j, Y') }}
@elseif($transfer->paid_until)
Paid through {{ $transfer->paid_until->format('M j, Y') }}
@else
Billing pending
@endif
· {{ $fmtBytes($transfer->storage_bytes) }} stored · GHS {{ number_format($transfer->monthlyCostGhs(), 2) }}/mo
</p>
</div> </div>
<form method="post" action="{{ route('transfer.transfers.destroy', $transfer) }}" onsubmit="return confirm('Delete this transfer and its files?')"> <form method="post" action="{{ route('transfer.transfers.destroy', $transfer) }}" onsubmit="return confirm('Delete this transfer and its files?')">
@csrf @csrf
+1 -7
View File
@@ -8,10 +8,4 @@ Artisan::command('inspire', function () {
$this->comment(Inspiring::quote()); $this->comment(Inspiring::quote());
})->purpose('Display an inspiring quote'); })->purpose('Display an inspiring quote');
Schedule::command('hosting:check-node-health --all')->everyFiveMinutes()->withoutOverlapping(); Schedule::command('transfer:process-billing')->daily()->withoutOverlapping();
Schedule::command('hosting:sync-account-usage')->hourly()->withoutOverlapping();
Schedule::command('hosting:recalculate-node-capacity --shared-only')->daily()->withoutOverlapping();
Schedule::command('hosting:process-expired-accounts')->daily()->withoutOverlapping();
Schedule::command('hosting:notify-expiring')->daily()->withoutOverlapping();
Schedule::command('hosting:retry-pending-fulfillment')->everyFifteenMinutes()->withoutOverlapping();
Schedule::command('ssl:renew')->daily()->withoutOverlapping();
+24
View File
@@ -0,0 +1,24 @@
<?php
namespace Tests\Concerns;
use Illuminate\Support\Facades\Http;
trait FakesTransferBilling
{
protected function fakeTransferBillingApi(): void
{
$base = rtrim((string) config('billing.api_url'), '/');
Http::fake([
$base.'/can-afford*' => Http::response(['affordable' => true]),
$base.'/debit*' => Http::response(['ok' => true]),
$base.'/balance*' => Http::response(['balance_minor' => 100000]),
$base.'/service-ledger*' => Http::response([
'spent_minor' => 0,
'credited_minor' => 0,
'net_minor' => 0,
]),
]);
}
}
+3
View File
@@ -8,16 +8,19 @@ use Illuminate\Http\UploadedFile;
use Illuminate\Support\Facades\Http; use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\Storage; use Illuminate\Support\Facades\Storage;
use Illuminate\Support\Str; use Illuminate\Support\Str;
use Tests\Concerns\FakesTransferBilling;
use Tests\TestCase; use Tests\TestCase;
class TransferApiTest extends TestCase class TransferApiTest extends TestCase
{ {
use FakesTransferBilling;
use RefreshDatabase; use RefreshDatabase;
protected function setUp(): void protected function setUp(): void
{ {
parent::setUp(); parent::setUp();
Storage::fake('qr'); Storage::fake('qr');
$this->fakeTransferBillingApi();
config([ config([
'transfer.service_api_keys' => ['webmail' => 'test-webmail-key'], 'transfer.service_api_keys' => ['webmail' => 'test-webmail-key'],
'identity.api_url' => 'https://ladill.com/api', 'identity.api_url' => 'https://ladill.com/api',
+110
View File
@@ -0,0 +1,110 @@
<?php
namespace Tests\Feature;
use App\Models\Transfer;
use App\Models\User;
use App\Services\Transfer\TransferBillingService;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Str;
use Tests\Concerns\FakesTransferBilling;
use Tests\TestCase;
class TransferBillingTest extends TestCase
{
use FakesTransferBilling;
use RefreshDatabase;
public function test_failed_renewal_enters_grace_period(): void
{
$user = User::create([
'public_id' => (string) Str::uuid(),
'name' => 'Grace User',
'email' => 'grace+'.uniqid().'@example.com',
]);
$transfer = Transfer::create([
'user_id' => $user->id,
'title' => 'Due soon',
'retention_days' => 30,
'paid_until' => now()->subMinute(),
'status' => Transfer::STATUS_ACTIVE,
'storage_bytes' => 1024 * 1024 * 1024,
]);
$base = rtrim((string) config('billing.api_url'), '/');
Http::fake([
$base.'/can-afford*' => Http::response(['affordable' => false]),
]);
$result = app(TransferBillingService::class)->processDueRenewals();
$transfer->refresh();
$this->assertSame(1, $result['graced']);
$this->assertSame(Transfer::STATUS_GRACE, $transfer->status);
$this->assertNotNull($transfer->grace_ends_at);
$this->assertTrue($transfer->grace_ends_at->isFuture());
$this->assertTrue($transfer->isAccessible());
}
public function test_transfer_past_grace_period_is_deleted(): void
{
$user = User::create([
'public_id' => (string) Str::uuid(),
'name' => 'Delete User',
'email' => 'delete+'.uniqid().'@example.com',
]);
Transfer::create([
'user_id' => $user->id,
'title' => 'Expired',
'retention_days' => 30,
'paid_until' => now()->subDays(20),
'grace_ends_at' => now()->subMinute(),
'status' => Transfer::STATUS_GRACE,
'storage_bytes' => 1024,
]);
$base = rtrim((string) config('billing.api_url'), '/');
Http::fake([
$base.'/can-afford*' => Http::response(['affordable' => false]),
]);
$result = app(TransferBillingService::class)->processDueRenewals();
$this->assertSame(1, $result['deleted']);
$this->assertDatabaseHas('transfers', [
'title' => 'Expired',
'status' => Transfer::STATUS_DELETED,
]);
}
public function test_successful_renewal_extends_paid_until(): void
{
$user = User::create([
'public_id' => (string) Str::uuid(),
'name' => 'Renew User',
'email' => 'renew+'.uniqid().'@example.com',
]);
$transfer = Transfer::create([
'user_id' => $user->id,
'title' => 'Renew me',
'retention_days' => 30,
'paid_until' => now()->subMinute(),
'status' => Transfer::STATUS_ACTIVE,
'storage_bytes' => 1024 * 1024 * 1024,
]);
$this->fakeTransferBillingApi();
$result = app(TransferBillingService::class)->processDueRenewals();
$transfer->refresh();
$this->assertSame(1, $result['renewed']);
$this->assertSame(Transfer::STATUS_ACTIVE, $transfer->status);
$this->assertTrue($transfer->paid_until->isFuture());
$this->assertNull($transfer->grace_ends_at);
}
}
+5 -2
View File
@@ -8,15 +8,18 @@ use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Http\UploadedFile; use Illuminate\Http\UploadedFile;
use Illuminate\Support\Facades\Storage; use Illuminate\Support\Facades\Storage;
use Illuminate\Support\Str; use Illuminate\Support\Str;
use Tests\Concerns\FakesTransferBilling;
use Tests\TestCase; use Tests\TestCase;
class TransferTest extends TestCase class TransferTest extends TestCase
{ {
use FakesTransferBilling;
use RefreshDatabase; use RefreshDatabase;
public function test_authenticated_user_can_create_transfer_with_files(): void public function test_authenticated_user_can_create_transfer_with_files(): void
{ {
Storage::fake('qr'); Storage::fake('qr');
$this->fakeTransferBillingApi();
$user = User::create([ $user = User::create([
'public_id' => (string) Str::uuid(), 'public_id' => (string) Str::uuid(),
@@ -27,7 +30,6 @@ class TransferTest extends TestCase
$response = $this->actingAs($user)->post(route('transfer.transfers.store'), [ $response = $this->actingAs($user)->post(route('transfer.transfers.store'), [
'title' => 'Project assets', 'title' => 'Project assets',
'message' => 'Here are the files.', 'message' => 'Here are the files.',
'retention_days' => 14,
'files' => [ 'files' => [
UploadedFile::fake()->create('brief.pdf', 100, 'application/pdf'), UploadedFile::fake()->create('brief.pdf', 100, 'application/pdf'),
UploadedFile::fake()->create('photo.jpg', 50, 'image/jpeg'), UploadedFile::fake()->create('photo.jpg', 50, 'image/jpeg'),
@@ -41,7 +43,8 @@ class TransferTest extends TestCase
$transfer = Transfer::first(); $transfer = Transfer::first();
$this->assertNotNull($transfer->qr_code_id); $this->assertNotNull($transfer->qr_code_id);
$this->assertSame(14, $transfer->retention_days); $this->assertNotNull($transfer->paid_until);
$this->assertTrue($transfer->paid_until->isFuture());
} }
public function test_dashboard_requires_auth(): void public function test_dashboard_requires_auth(): void