diff --git a/.env.example b/.env.example index 60652d8..8e7f105 100644 --- a/.env.example +++ b/.env.example @@ -38,6 +38,8 @@ IDENTITY_API_URL=https://ladill.com/api IDENTITY_API_KEY_TRANSFER= 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). TRANSFER_MAX_FILE_BYTES=0 TRANSFER_MAX_FILES=20 diff --git a/app/Console/Commands/ProcessTransferBillingCommand.php b/app/Console/Commands/ProcessTransferBillingCommand.php new file mode 100644 index 0000000..ce438fd --- /dev/null +++ b/app/Console/Commands/ProcessTransferBillingCommand.php @@ -0,0 +1,27 @@ +processDueRenewals(); + + $this->info(sprintf( + 'Transfer billing: %d renewed, %d entered grace, %d deleted.', + $result['renewed'], + $result['graced'], + $result['deleted'], + )); + + return self::SUCCESS; + } +} diff --git a/app/Http/Controllers/Api/ChunkedUploadController.php b/app/Http/Controllers/Api/ChunkedUploadController.php index 1fb58af..edc90c9 100644 --- a/app/Http/Controllers/Api/ChunkedUploadController.php +++ b/app/Http/Controllers/Api/ChunkedUploadController.php @@ -140,7 +140,8 @@ class ChunkedUploadController extends Controller 'id' => $transfer->id, 'title' => $transfer->title, '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) => [ 'name' => $file->original_name, 'size_bytes' => $file->size_bytes, diff --git a/app/Http/Controllers/Api/TransferController.php b/app/Http/Controllers/Api/TransferController.php index a56c205..31d782e 100644 --- a/app/Http/Controllers/Api/TransferController.php +++ b/app/Http/Controllers/Api/TransferController.php @@ -69,7 +69,8 @@ class TransferController extends Controller 'id' => $transfer->id, 'title' => $transfer->title, '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) => [ 'name' => $file->original_name, 'size_bytes' => $file->size_bytes, diff --git a/app/Http/Controllers/Qr/AccountController.php b/app/Http/Controllers/Qr/AccountController.php index a2b9fbd..c270e09 100644 --- a/app/Http/Controllers/Qr/AccountController.php +++ b/app/Http/Controllers/Qr/AccountController.php @@ -52,10 +52,7 @@ class AccountController extends Controller $activeTransfers = Transfer::query() ->where('user_id', $user->id) - ->where('status', Transfer::STATUS_ACTIVE) - ->where(function ($query) { - $query->whereNull('expires_at')->orWhere('expires_at', '>', now()); - }) + ->accessible() ->orderByDesc('storage_bytes') ->get(); diff --git a/app/Http/Controllers/Transfer/OverviewController.php b/app/Http/Controllers/Transfer/OverviewController.php index 227d14c..ce71a14 100644 --- a/app/Http/Controllers/Transfer/OverviewController.php +++ b/app/Http/Controllers/Transfer/OverviewController.php @@ -21,7 +21,7 @@ class OverviewController extends Controller $transfers = Transfer::query() ->where('user_id', $account->id) - ->where('status', Transfer::STATUS_ACTIVE); + ->accessible(); $activeCount = (clone $transfers)->count(); $storageBytes = (int) (clone $transfers)->sum('storage_bytes'); @@ -37,14 +37,14 @@ class OverviewController extends Controller $expiringSoon = Transfer::query() ->where('user_id', $account->id) - ->where('status', Transfer::STATUS_ACTIVE) - ->whereNotNull('expires_at') - ->whereBetween('expires_at', [now(), now()->addDays(7)]) + ->accessible() + ->whereNotNull('paid_until') + ->whereBetween('paid_until', [now(), now()->addDays(7)]) ->count(); $recentTransfers = Transfer::query() ->where('user_id', $account->id) - ->where('status', Transfer::STATUS_ACTIVE) + ->accessible() ->with('qrCode') ->latest() ->limit(6) diff --git a/app/Http/Controllers/Transfer/TransferController.php b/app/Http/Controllers/Transfer/TransferController.php index 2a7292b..bd579cd 100644 --- a/app/Http/Controllers/Transfer/TransferController.php +++ b/app/Http/Controllers/Transfer/TransferController.php @@ -31,7 +31,7 @@ class TransferController extends Controller $transfers = Transfer::query() ->where('user_id', $account->id) - ->where('status', Transfer::STATUS_ACTIVE) + ->accessible() ->with(['qrCode', 'files']) ->latest() ->paginate(20); @@ -42,9 +42,9 @@ class TransferController extends Controller public function create(): View { return view('transfer.transfers.create', [ - 'defaultRetentionDays' => (int) config('transfer.default_retention_days', 30), 'maxFiles' => (int) config('transfer.max_files_per_transfer', 20), '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', 'message' => 'nullable|string|max:2000', 'password' => 'nullable|string|min:4|max:64', - 'retention_days' => 'nullable|integer|min:1|max:365', 'files' => 'nullable|array', 'files.*' => $fileRules, 'upload_ids' => 'nullable|array', diff --git a/app/Models/Transfer.php b/app/Models/Transfer.php index 8615faa..68a8954 100644 --- a/app/Models/Transfer.php +++ b/app/Models/Transfer.php @@ -2,6 +2,8 @@ namespace App\Models; +use Carbon\CarbonInterface; +use Illuminate\Database\Eloquent\Builder; use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Relations\BelongsTo; use Illuminate\Database\Eloquent\Relations\HasMany; @@ -10,6 +12,8 @@ class Transfer extends Model { public const STATUS_ACTIVE = 'active'; + public const STATUS_GRACE = 'grace'; + public const STATUS_EXPIRED = 'expired'; public const STATUS_DELETED = 'deleted'; @@ -22,6 +26,9 @@ class Transfer extends Model 'password_hash', 'retention_days', 'expires_at', + 'paid_until', + 'grace_ends_at', + 'last_billed_at', 'status', 'downloads_total', 'storage_bytes', @@ -30,6 +37,9 @@ class Transfer extends Model protected $casts = [ 'retention_days' => 'integer', 'expires_at' => 'datetime', + 'paid_until' => 'datetime', + 'grace_ends_at' => 'datetime', + 'last_billed_at' => 'datetime', 'downloads_total' => 'integer', 'storage_bytes' => 'integer', ]; @@ -54,17 +64,52 @@ class Transfer extends Model 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 { - 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; } - 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 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 @@ -81,4 +126,27 @@ class Transfer extends Model { 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()); + }); + } } diff --git a/app/Services/Afia/AfiaService.php b/app/Services/Afia/AfiaService.php index 00c93b4..5519fc4 100644 --- a/app/Services/Afia/AfiaService.php +++ b/app/Services/Afia/AfiaService.php @@ -115,8 +115,9 @@ class AfiaService - Settings: notification preferences for transfer activity. Pricing: - - Storage retention is prepaid at GHS {$pricePerGb} per GB per month (metered on actual storage; no free tier). - - Wallet must have funds to keep transfers active through their retention period. + - Storage is billed monthly at GHS {$pricePerGb} per GB (metered on actual storage; no free tier). + - 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: - Only answer questions about Ladill Transfer — uploads, share links, QR codes, passwords, retention/expiry, downloads, storage billing, team, and wallet. diff --git a/app/Services/Transfer/TransferBillingService.php b/app/Services/Transfer/TransferBillingService.php new file mode 100644 index 0000000..3b85cce --- /dev/null +++ b/app/Services/Transfer/TransferBillingService.php @@ -0,0 +1,166 @@ +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')); + } +} diff --git a/app/Services/Transfer/TransferService.php b/app/Services/Transfer/TransferService.php index 8a2d65d..bfd3389 100644 --- a/app/Services/Transfer/TransferService.php +++ b/app/Services/Transfer/TransferService.php @@ -39,10 +39,9 @@ class TransferService } $maxBytes = (int) config('transfer.max_file_bytes', 0); - $retentionDays = (int) ($data['retention_days'] ?? config('transfer.default_retention_days', 30)); - $retentionDays = max(1, min(365, $retentionDays)); + $billingPeriodDays = (int) config('transfer.billing_period_days', 30); - return DB::transaction(function () use ($user, $data, $files, $maxBytes, $retentionDays) { + return DB::transaction(function () use ($user, $data, $files, $maxBytes, $billingPeriodDays) { $passwordHash = null; if (! empty($data['password'])) { $passwordHash = Hash::make((string) $data['password']); @@ -53,8 +52,7 @@ class TransferService 'title' => trim((string) $data['title']), 'message' => isset($data['message']) ? trim((string) $data['message']) : null, 'password_hash' => $passwordHash, - 'retention_days' => $retentionDays, - 'expires_at' => now()->addDays($retentionDays), + 'retention_days' => $billingPeriodDays, 'status' => Transfer::STATUS_ACTIVE, ]); @@ -82,6 +80,9 @@ class TransferService 'storage_bytes' => $totalBytes, ]); + $transfer = $transfer->fresh(['files', 'qrCode']); + app(TransferBillingService::class)->chargeInitialPeriod($transfer, $user); + return $transfer->fresh(['files', 'qrCode']); }); } diff --git a/config/transfer.php b/config/transfer.php index 9e836a5..6685e82 100644 --- a/config/transfer.php +++ b/config/transfer.php @@ -23,9 +23,13 @@ return [ // Max files per transfer. 'max_files_per_transfer' => (int) env('TRANSFER_MAX_FILES', 20), - // Default retention when not specified (days). - 'default_retention_days' => (int) env('TRANSFER_DEFAULT_RETENTION_DAYS', 30), + // Monthly billing cycle length (days) — storage is renewed each period while the wallet can pay. + '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), ]; diff --git a/database/migrations/2026_06_09_120000_add_transfer_billing_period_columns.php b/database/migrations/2026_06_09_120000_add_transfer_billing_period_columns.php new file mode 100644 index 0000000..2b73436 --- /dev/null +++ b/database/migrations/2026_06_09_120000_add_transfer_billing_period_columns.php @@ -0,0 +1,35 @@ +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']); + }); + } +}; diff --git a/deploy/shared.env.example b/deploy/shared.env.example index a7b2a78..67590d8 100644 --- a/deploy/shared.env.example +++ b/deploy/shared.env.example @@ -40,6 +40,8 @@ IDENTITY_API_KEY_TRANSFER= TRANSFER_PRICE_PER_GB_MONTH=0.30 TRANSFER_MAX_FILE_BYTES=0 TRANSFER_MAX_FILES=20 +TRANSFER_BILLING_PERIOD_DAYS=30 +TRANSFER_GRACE_PERIOD_DAYS=15 TRANSFER_DEFAULT_RETENTION_DAYS=30 TRANSFER_MAIL_RETENTION_DAYS=30 diff --git a/resources/views/qr/account/billing.blade.php b/resources/views/qr/account/billing.blade.php index bba14a8..ecc02e0 100644 --- a/resources/views/qr/account/billing.blade.php +++ b/resources/views/qr/account/billing.blade.php @@ -15,7 +15,7 @@ @endphp
Storage is metered at GHS {{ number_format($pricePerGb, 2) }} per GB per month of retention and debited from your Ladill wallet.
+Storage is billed monthly at GHS {{ number_format($pricePerGb, 2) }} per GB while each transfer stays active. Renewals debit your Ladill wallet; unpaid transfers are kept for {{ (int) config('transfer.grace_period_days', 15) }} days before deletion.
{{ $fmtBytes($transfer->storage_bytes) }} - @if($transfer->expires_at) - · expires {{ $transfer->expires_at->format('M j, Y') }} + @if($transfer->isInGracePeriod()) + · 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
Retention is prepaid from your wallet. Keep it funded so transfers stay available through their expiry date.
+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.
diff --git a/resources/views/transfer/dashboard.blade.php b/resources/views/transfer/dashboard.blade.php index 27490b7..d08604a 100644 --- a/resources/views/transfer/dashboard.blade.php +++ b/resources/views/transfer/dashboard.blade.php @@ -58,7 +58,7 @@Wallet balance
{{ $fmt($balanceMinor) }}
-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.
+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.
Upload files, set how long they stay hosted, and get a share link + QR code.
+Upload files and get a share link + QR code. Storage is billed monthly from your Ladill wallet while the transfer stays active.
{{ $message }}
@enderrorGHS {{ number_format($pricePerGb, 2) }}/GB/month while files are stored.
-Monthly storage billing
+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.
+Expires {{ $transfer->expires_at?->format('M j, Y') ?? '—' }} · {{ $fmtBytes($transfer->storage_bytes) }} stored
++ @if($transfer->isInGracePeriod()) + Payment due · 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 +