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:
@@ -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
@@ -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());
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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'));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
@@ -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']);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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
@@ -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();
|
|
||||||
|
|||||||
@@ -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,
|
||||||
|
]),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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',
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
|||||||
Reference in New Issue
Block a user