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
@@ -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,
'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,
@@ -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,
@@ -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();
@@ -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)
@@ -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',
+71 -3
View File
@@ -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());
});
}
}
+3 -2
View File
@@ -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.
@@ -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);
$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']);
});
}