Bill transfer storage monthly with a 15-day grace period before deletion.
Deploy Ladill Transfer / deploy (push) Successful in 51s
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:
@@ -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
@@ -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());
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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'));
|
||||
}
|
||||
}
|
||||
@@ -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']);
|
||||
});
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user